Skip to main content

reqsign_google/provide_credential/
external_account.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::BTreeMap;
19use std::fmt::{Debug, Formatter};
20use std::time::Duration;
21
22use form_urlencoded::Serializer;
23use http::Method;
24use http::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE};
25use log::{debug, error};
26use reqsign_aws_v4::{
27    Credential as AwsCredential, EnvCredentialProvider as AwsEnvCredentialProvider,
28    RequestSigner as AwsRequestSigner,
29};
30use serde::{Deserialize, Serialize};
31
32use crate::credential::{
33    Credential, ExternalAccount, Token, external_account, parse_service_account_impersonation_url,
34};
35use crate::service_account_impersonation::generate_access_token;
36use reqsign_core::time::Timestamp;
37use reqsign_core::{Context, Error, ProvideCredential, Result, SignRequest};
38
39/// The maximum impersonated token lifetime allowed, 1 hour.
40const MAX_LIFETIME: Duration = Duration::from_secs(3600);
41/// Default timeout declared by AIP-4117 for executable sources.
42const DEFAULT_EXECUTABLE_TIMEOUT: Duration = Duration::from_secs(30);
43/// Gate required by AIP-4117 before executable sources may be used.
44const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES: &str = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES";
45const GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE: &str = "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE";
46const GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE: &str = "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE";
47const GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL: &str =
48    "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL";
49const GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE: &str = "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE";
50const EXECUTABLE_RESPONSE_VERSION: u64 = 1;
51const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
52const TOKEN_TYPE_ID_TOKEN: &str = "urn:ietf:params:oauth:token-type:id_token";
53const TOKEN_TYPE_SAML2: &str = "urn:ietf:params:oauth:token-type:saml2";
54const TOKEN_TYPE_AWS4_REQUEST: &str = "urn:ietf:params:aws:token-type:aws4_request";
55const AWS_REGION: &str = "AWS_REGION";
56const AWS_DEFAULT_REGION: &str = "AWS_DEFAULT_REGION";
57#[cfg(test)]
58const AWS_ACCESS_KEY_ID: &str = "AWS_ACCESS_KEY_ID";
59#[cfg(test)]
60const AWS_SECRET_ACCESS_KEY: &str = "AWS_SECRET_ACCESS_KEY";
61#[cfg(test)]
62const AWS_SESSION_TOKEN: &str = "AWS_SESSION_TOKEN";
63const AWS_EC2_METADATA_DISABLED: &str = "AWS_EC2_METADATA_DISABLED";
64const AWS_IMDSV2_TOKEN_HEADER: &str = "x-aws-ec2-metadata-token";
65const AWS_IMDSV2_TTL_HEADER: &str = "x-aws-ec2-metadata-token-ttl-seconds";
66const AWS_IMDSV2_TTL_SECONDS: &str = "300";
67/// Scope required on the STS token used to call IAM Credentials `generateAccessToken`.
68///
69/// Caller business scopes (for example Storage) must not be used here: that token only
70/// authorizes the impersonation API. Either `iam` or `cloud-platform` is accepted by Google;
71/// prefer the narrower `iam` scope for the intermediate credential.
72const STS_IMPERSONATION_SCOPE: &str = "https://www.googleapis.com/auth/iam";
73
74/// STS token response.
75#[derive(Deserialize)]
76struct StsTokenResponse {
77    access_token: String,
78    expires_in: Option<u64>,
79}
80
81#[derive(Deserialize)]
82struct ExecutableResponse {
83    version: u64,
84    success: bool,
85    #[serde(default)]
86    token_type: Option<String>,
87    #[serde(default)]
88    id_token: Option<String>,
89    #[serde(default)]
90    saml_response: Option<String>,
91    #[serde(default)]
92    expiration_time: Option<i64>,
93    #[serde(default)]
94    code: Option<String>,
95    #[serde(default)]
96    message: Option<String>,
97}
98
99struct ExecutableSubjectToken {
100    token: String,
101    expires_at: Option<Timestamp>,
102}
103
104#[derive(Deserialize)]
105#[serde(rename_all = "PascalCase")]
106struct AwsMetadataCredentialResponse {
107    #[serde(default)]
108    access_key_id: String,
109    #[serde(default)]
110    secret_access_key: String,
111    #[serde(default)]
112    token: Option<String>,
113    #[serde(default)]
114    expiration: Option<String>,
115    #[serde(default)]
116    code: Option<String>,
117    #[serde(default)]
118    message: Option<String>,
119}
120
121#[derive(Serialize)]
122struct AwsSignedRequest {
123    url: String,
124    method: String,
125    headers: Vec<AwsSignedHeader>,
126    body: String,
127}
128
129#[derive(Serialize)]
130struct AwsSignedHeader {
131    key: String,
132    value: String,
133}
134
135/// Google configuration for exchanging an external subject token.
136#[derive(Clone, Debug)]
137pub struct ExternalAccountConfig {
138    audience: String,
139    subject_token_type: String,
140    token_url: String,
141    service_account_impersonation_url: Option<String>,
142    service_account_impersonation_lifetime: Option<Duration>,
143}
144
145impl ExternalAccountConfig {
146    /// Create the service-owned configuration for an external-account exchange.
147    pub fn new(
148        audience: impl Into<String>,
149        subject_token_type: impl Into<String>,
150        token_url: impl Into<String>,
151    ) -> Self {
152        Self {
153            audience: audience.into(),
154            subject_token_type: subject_token_type.into(),
155            token_url: token_url.into(),
156            service_account_impersonation_url: None,
157            service_account_impersonation_lifetime: None,
158        }
159    }
160
161    /// Configure service-account impersonation after the STS exchange.
162    pub fn with_service_account_impersonation_url(mut self, url: impl Into<String>) -> Self {
163        self.service_account_impersonation_url = Some(url.into());
164        self
165    }
166
167    /// Configure the requested service-account impersonation token lifetime.
168    pub fn with_service_account_impersonation_lifetime(mut self, lifetime: Duration) -> Self {
169        self.service_account_impersonation_lifetime = Some(lifetime);
170        self
171    }
172
173    fn from_external_account(external_account: &ExternalAccount) -> Self {
174        Self {
175            audience: external_account.audience.clone(),
176            subject_token_type: external_account.subject_token_type.clone(),
177            token_url: external_account.token_url.clone(),
178            service_account_impersonation_url: external_account
179                .service_account_impersonation_url
180                .clone(),
181            service_account_impersonation_lifetime: external_account
182                .service_account_impersonation
183                .as_ref()
184                .and_then(|options| options.token_lifetime_seconds)
185                .map(|seconds| Duration::from_secs(seconds as u64)),
186        }
187    }
188}
189
190#[derive(Clone)]
191enum ExternalAccountSubjectTokenSource {
192    CredentialSource(external_account::Source),
193    Direct(LoadedSubjectToken),
194}
195
196#[derive(Clone)]
197struct LoadedSubjectToken {
198    token: String,
199    expires_at: Option<Timestamp>,
200}
201
202impl LoadedSubjectToken {
203    fn new(token: impl Into<String>, expires_at: Option<Timestamp>) -> Self {
204        Self {
205            token: token.into(),
206            expires_at,
207        }
208    }
209
210    fn validate(&self) -> Result<()> {
211        if self.token.trim().is_empty() {
212            return Err(Error::credential_invalid("subject token is empty"));
213        }
214        if self
215            .expires_at
216            .is_some_and(|expires_at| expires_at <= Timestamp::now())
217        {
218            return Err(Error::credential_invalid("subject token has expired"));
219        }
220        Ok(())
221    }
222}
223
224/// Exchanges an external subject token for a Google access token.
225#[derive(Clone)]
226pub struct ExternalAccountCredentialProvider {
227    config: ExternalAccountConfig,
228    subject_token_source: ExternalAccountSubjectTokenSource,
229    scope: Option<String>,
230}
231
232impl Debug for ExternalAccountCredentialProvider {
233    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
234        let subject_token_source = match &self.subject_token_source {
235            ExternalAccountSubjectTokenSource::CredentialSource(_) => "credential_source",
236            ExternalAccountSubjectTokenSource::Direct(_) => "direct",
237        };
238        f.debug_struct("ExternalAccountCredentialProvider")
239            .field("config", &self.config)
240            .field("subject_token_source", &subject_token_source)
241            .field("scope", &self.scope)
242            .finish()
243    }
244}
245
246impl ExternalAccountCredentialProvider {
247    pub(crate) fn new(external_account: ExternalAccount) -> Self {
248        let config = ExternalAccountConfig::from_external_account(&external_account);
249        Self {
250            config,
251            subject_token_source: ExternalAccountSubjectTokenSource::CredentialSource(
252                external_account.credential_source,
253            ),
254            scope: None,
255        }
256    }
257
258    /// Create a provider for one caller-bound subject token.
259    pub fn from_subject_token(
260        config: ExternalAccountConfig,
261        subject_token: impl Into<String>,
262    ) -> Self {
263        Self {
264            config,
265            subject_token_source: ExternalAccountSubjectTokenSource::Direct(
266                LoadedSubjectToken::new(subject_token, None),
267            ),
268            scope: None,
269        }
270    }
271
272    /// Create a provider for a caller-bound subject token with absolute expiration.
273    pub fn from_subject_token_and_expiration(
274        config: ExternalAccountConfig,
275        subject_token: impl Into<String>,
276        expires_at: Timestamp,
277    ) -> Self {
278        Self {
279            config,
280            subject_token_source: ExternalAccountSubjectTokenSource::Direct(
281                LoadedSubjectToken::new(subject_token, Some(expires_at)),
282            ),
283            scope: None,
284        }
285    }
286
287    /// Set the OAuth2 scope.
288    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
289        self.scope = Some(scope.into());
290        self
291    }
292
293    fn resolve_scope(&self, ctx: &Context) -> String {
294        self.scope
295            .clone()
296            .or_else(|| ctx.env_var(crate::constants::GOOGLE_SCOPE))
297            .unwrap_or_else(|| crate::constants::DEFAULT_SCOPE.to_string())
298    }
299
300    /// Scope for the STS token exchange.
301    ///
302    /// When service account impersonation follows, the STS token is only used to call
303    /// IAM Credentials and must carry `iam` (or `cloud-platform`). The caller's business
304    /// scopes are requested later via `generateAccessToken`. Without impersonation, STS
305    /// returns the final token and therefore uses the caller scope directly.
306    fn resolve_sts_scope(&self, ctx: &Context) -> String {
307        if self.config.service_account_impersonation_url.is_some() {
308            STS_IMPERSONATION_SCOPE.to_string()
309        } else {
310            self.resolve_scope(ctx)
311        }
312    }
313
314    #[cfg(test)]
315    async fn load_oidc_token(&self, ctx: &Context) -> Result<String> {
316        Ok(self.load_subject_token(ctx).await?.token)
317    }
318
319    async fn load_subject_token(&self, ctx: &Context) -> Result<LoadedSubjectToken> {
320        let subject_token = match &self.subject_token_source {
321            ExternalAccountSubjectTokenSource::Direct(subject_token) => subject_token.clone(),
322            ExternalAccountSubjectTokenSource::CredentialSource(source) => {
323                let token = match source {
324                    external_account::Source::Aws(source) => {
325                        self.load_aws_subject_token(ctx, source).await?
326                    }
327                    external_account::Source::File(source) => {
328                        self.load_file_sourced_token(ctx, source).await?
329                    }
330                    external_account::Source::Url(source) => {
331                        self.load_url_sourced_token(ctx, source).await?
332                    }
333                    external_account::Source::Executable(source) => {
334                        self.load_executable_sourced_token(ctx, source).await?
335                    }
336                };
337                LoadedSubjectToken::new(token, None)
338            }
339        };
340        subject_token.validate()?;
341        Ok(subject_token)
342    }
343
344    async fn load_file_sourced_token(
345        &self,
346        ctx: &Context,
347        source: &external_account::FileSource,
348    ) -> Result<String> {
349        let file = resolve_template(ctx, &source.file)?;
350        debug!("loading OIDC token from file: {}", file);
351
352        let content = ctx.file_read(&file).await?;
353        let token = source.format.parse(&content)?;
354        let token = token.trim().to_string();
355        if token.is_empty() {
356            return Err(reqsign_core::Error::credential_invalid(
357                "OIDC token loaded from file is empty",
358            ));
359        }
360
361        Ok(token)
362    }
363
364    async fn load_url_sourced_token(
365        &self,
366        ctx: &Context,
367        source: &external_account::UrlSource,
368    ) -> Result<String> {
369        let url = resolve_template(ctx, &source.url)?;
370        debug!("loading OIDC token from URL: {}", url);
371
372        let mut req = http::Request::get(&url);
373
374        // Add custom headers if any
375        if let Some(headers) = &source.headers {
376            for (key, value) in headers {
377                let value = resolve_template(ctx, value)?;
378                req = req.header(key, value);
379            }
380        }
381
382        let resp = ctx
383            .http_send(req.body(Vec::<u8>::new().into()).map_err(|e| {
384                reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
385            })?)
386            .await?;
387
388        if resp.status() != http::StatusCode::OK {
389            let status = resp.status();
390            error!("external account subject-token URL returned {status}");
391            return Err(reqsign_core::Error::unexpected(
392                "external account subject-token request failed",
393            )
394            .with_context(format!("http_status: {status}")));
395        }
396
397        let token = source.format.parse(resp.body())?;
398        let token = token.trim().to_string();
399        if token.is_empty() {
400            return Err(reqsign_core::Error::credential_invalid(
401                "OIDC token loaded from URL is empty",
402            ));
403        }
404
405        Ok(token)
406    }
407
408    fn resolved_subject_token_type(&self, ctx: &Context) -> Result<String> {
409        resolve_template(ctx, &self.config.subject_token_type)
410    }
411
412    fn validate_aws_source(
413        &self,
414        ctx: &Context,
415        source: &external_account::AwsSource,
416    ) -> Result<ResolvedAwsSource> {
417        if source.environment_id != "aws1" {
418            return Err(reqsign_core::Error::config_invalid(format!(
419                "unsupported AWS external_account environment_id: {}",
420                source.environment_id
421            )));
422        }
423
424        let region_url = source
425            .region_url
426            .as_deref()
427            .map(|v| resolve_template(ctx, v))
428            .transpose()?;
429        let url = source
430            .url
431            .as_deref()
432            .map(|v| resolve_template(ctx, v))
433            .transpose()?;
434        let regional_cred_verification_url =
435            resolve_template(ctx, &source.regional_cred_verification_url)?;
436        let imdsv2_session_token_url = source
437            .imdsv2_session_token_url
438            .as_deref()
439            .map(|v| resolve_template(ctx, v))
440            .transpose()?;
441
442        for (field, value) in [
443            ("credential_source.region_url", region_url.as_deref()),
444            ("credential_source.url", url.as_deref()),
445            (
446                "credential_source.imdsv2_session_token_url",
447                imdsv2_session_token_url.as_deref(),
448            ),
449        ] {
450            if let Some(value) = value {
451                validate_aws_metadata_url(field, value)?;
452            }
453        }
454
455        Ok(ResolvedAwsSource {
456            region_url,
457            url,
458            regional_cred_verification_url,
459            imdsv2_session_token_url,
460        })
461    }
462
463    async fn load_aws_subject_token(
464        &self,
465        ctx: &Context,
466        source: &external_account::AwsSource,
467    ) -> Result<String> {
468        let subject_token_type = self.resolved_subject_token_type(ctx)?;
469        if subject_token_type != TOKEN_TYPE_AWS4_REQUEST {
470            return Err(reqsign_core::Error::config_invalid(format!(
471                "AWS credential_source requires subject_token_type {TOKEN_TYPE_AWS4_REQUEST}, got {subject_token_type}"
472            )));
473        }
474
475        let source = self.validate_aws_source(ctx, source)?;
476        let metadata_token = self.load_aws_imdsv2_token_if_needed(ctx, &source).await?;
477        let region = self
478            .resolve_aws_region(ctx, &source, metadata_token.as_deref())
479            .await?;
480        let credential = self
481            .resolve_aws_credential(ctx, &source, metadata_token.as_deref())
482            .await?;
483        self.build_aws_subject_token(ctx, &source, &region, credential)
484            .await
485    }
486
487    async fn resolve_aws_region(
488        &self,
489        ctx: &Context,
490        source: &ResolvedAwsSource,
491        metadata_token: Option<&str>,
492    ) -> Result<String> {
493        if let Some(region) = ctx
494            .env_var(AWS_REGION)
495            .filter(|v| !v.trim().is_empty())
496            .or_else(|| {
497                ctx.env_var(AWS_DEFAULT_REGION)
498                    .filter(|v| !v.trim().is_empty())
499            })
500        {
501            return Ok(region);
502        }
503
504        let region_url = source.region_url.as_deref().ok_or_else(|| {
505            reqsign_core::Error::config_invalid(
506                "credential_source.region_url is required when AWS region env vars are absent",
507            )
508        })?;
509        let zone = fetch_aws_metadata_text(ctx, region_url, metadata_token).await?;
510        availability_zone_to_region(zone.trim())
511    }
512
513    async fn resolve_aws_credential(
514        &self,
515        ctx: &Context,
516        source: &ResolvedAwsSource,
517        metadata_token: Option<&str>,
518    ) -> Result<AwsCredential> {
519        if let Some(credential) = AwsEnvCredentialProvider::new()
520            .provide_credential(ctx)
521            .await?
522        {
523            return Ok(credential);
524        }
525
526        let credentials_url = source.url.as_deref().ok_or_else(|| {
527            reqsign_core::Error::config_invalid(
528                "credential_source.url is required when AWS credential env vars are absent",
529            )
530        })?;
531        let role_name = fetch_aws_metadata_text(ctx, credentials_url, metadata_token).await?;
532        let role_name = role_name.trim();
533        if role_name.is_empty() {
534            return Err(reqsign_core::Error::credential_invalid(
535                "AWS metadata credentials role name is empty",
536            ));
537        }
538
539        let credentials_url = format!("{}/{}", credentials_url.trim_end_matches('/'), role_name);
540        let content = fetch_aws_metadata_text(ctx, &credentials_url, metadata_token).await?;
541        let response: AwsMetadataCredentialResponse = serde_json::from_str(content.trim())
542            .map_err(|e| {
543                reqsign_core::Error::unexpected("failed to parse AWS metadata credentials response")
544                    .with_source(e)
545            })?;
546
547        if let Some(code) = response.code.as_deref() {
548            if code != "Success" {
549                return Err(reqsign_core::Error::credential_invalid(format!(
550                    "AWS metadata credentials response returned [{}] {}",
551                    code,
552                    response.message.as_deref().unwrap_or_default()
553                )));
554            }
555        }
556        if response.access_key_id.is_empty() || response.secret_access_key.is_empty() {
557            return Err(reqsign_core::Error::credential_invalid(
558                "AWS metadata credentials response is missing access key id or secret access key",
559            ));
560        }
561
562        Ok(AwsCredential {
563            access_key_id: response.access_key_id,
564            secret_access_key: response.secret_access_key,
565            session_token: response.token.filter(|v| !v.trim().is_empty()),
566            expires_in: response
567                .expiration
568                .as_deref()
569                .map(str::trim)
570                .filter(|v| !v.is_empty())
571                .map(|v| {
572                    v.parse().map_err(|e| {
573                        reqsign_core::Error::unexpected(
574                            "failed to parse AWS metadata credential expiration",
575                        )
576                        .with_source(e)
577                    })
578                })
579                .transpose()?,
580        })
581    }
582
583    async fn load_aws_imdsv2_token_if_needed(
584        &self,
585        ctx: &Context,
586        source: &ResolvedAwsSource,
587    ) -> Result<Option<String>> {
588        let needs_metadata_region = ctx
589            .env_var(AWS_REGION)
590            .filter(|v| !v.trim().is_empty())
591            .or_else(|| {
592                ctx.env_var(AWS_DEFAULT_REGION)
593                    .filter(|v| !v.trim().is_empty())
594            })
595            .is_none()
596            && source.region_url.is_some();
597        let needs_metadata_cred = AwsEnvCredentialProvider::new()
598            .provide_credential(ctx)
599            .await?
600            .is_none()
601            && source.url.is_some();
602
603        let Some(token_url) = source.imdsv2_session_token_url.as_deref() else {
604            return Ok(None);
605        };
606        if !needs_metadata_region && !needs_metadata_cred {
607            return Ok(None);
608        }
609        if ctx
610            .env_var(AWS_EC2_METADATA_DISABLED)
611            .as_deref()
612            .is_some_and(|v| v.eq_ignore_ascii_case("true"))
613        {
614            return Err(reqsign_core::Error::config_invalid(
615                "AWS metadata access is disabled by AWS_EC2_METADATA_DISABLED",
616            ));
617        }
618        let req = http::Request::builder()
619            .method(Method::PUT)
620            .uri(token_url)
621            .header(CONTENT_LENGTH, "0")
622            .header(AWS_IMDSV2_TTL_HEADER, AWS_IMDSV2_TTL_SECONDS)
623            .body(Vec::<u8>::new().into())
624            .map_err(|e| {
625                reqsign_core::Error::unexpected("failed to build AWS IMDSv2 token request")
626                    .with_source(e)
627            })?;
628        let resp = ctx.http_send_as_string(req).await?;
629        if resp.status() != http::StatusCode::OK {
630            return Err(reqsign_core::Error::unexpected(format!(
631                "failed to fetch AWS IMDSv2 session token: {}",
632                resp.body()
633            )));
634        }
635        let token = resp.into_body();
636        if token.trim().is_empty() {
637            return Err(reqsign_core::Error::credential_invalid(
638                "AWS IMDSv2 session token is empty",
639            ));
640        }
641        Ok(Some(token))
642    }
643
644    async fn build_aws_subject_token(
645        &self,
646        ctx: &Context,
647        source: &ResolvedAwsSource,
648        region: &str,
649        credential: AwsCredential,
650    ) -> Result<String> {
651        let audience = resolve_template(ctx, &self.config.audience)?;
652        let verification_url = source
653            .regional_cred_verification_url
654            .replace("{region}", region);
655
656        let req = http::Request::builder()
657            .method(Method::POST)
658            .uri(&verification_url)
659            .header("x-goog-cloud-target-resource", audience)
660            .body(())
661            .map_err(|e| {
662                reqsign_core::Error::unexpected("failed to build AWS subject token request")
663                    .with_source(e)
664            })?;
665        let (mut parts, _body) = req.into_parts();
666        AwsRequestSigner::new("sts", region)
667            .sign_request(ctx, &mut parts, Some(&credential), None)
668            .await?;
669
670        let mut headers = parts
671            .headers
672            .iter()
673            .map(|(key, value)| {
674                Ok(AwsSignedHeader {
675                    key: aws_subject_header_name(key.as_str()),
676                    value: value
677                        .to_str()
678                        .map_err(|e| {
679                            reqsign_core::Error::unexpected("AWS signed header is not valid UTF-8")
680                                .with_source(e)
681                        })?
682                        .to_string(),
683                })
684            })
685            .collect::<Result<Vec<_>>>()?;
686        headers.sort_by(|a, b| a.key.cmp(&b.key));
687
688        serde_json::to_string(&AwsSignedRequest {
689            url: parts.uri.to_string(),
690            method: parts.method.as_str().to_string(),
691            headers,
692            body: String::new(),
693        })
694        .map_err(|e| {
695            reqsign_core::Error::unexpected("failed to serialize AWS subject token").with_source(e)
696        })
697    }
698
699    fn build_executable_env(
700        &self,
701        ctx: &Context,
702        output_file: Option<&str>,
703    ) -> Result<BTreeMap<String, String>> {
704        let mut envs = BTreeMap::new();
705        envs.insert(
706            GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE.to_string(),
707            resolve_template(ctx, &self.config.audience)?,
708        );
709        envs.insert(
710            GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE.to_string(),
711            self.resolved_subject_token_type(ctx)?,
712        );
713
714        if let Some(url) = &self.config.service_account_impersonation_url {
715            let url = resolve_template(ctx, url)?;
716            let email = parse_service_account_impersonation_url(&url)?;
717            envs.insert(
718                GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL.to_string(),
719                email,
720            );
721        }
722
723        if let Some(path) = output_file {
724            envs.insert(
725                GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE.to_string(),
726                path.to_string(),
727            );
728        }
729
730        Ok(envs)
731    }
732
733    fn validate_executable_usage(
734        &self,
735        ctx: &Context,
736        source: &external_account::ExecutableSource,
737    ) -> Result<(String, Duration, Option<String>)> {
738        if ctx
739            .env_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES)
740            .as_deref()
741            != Some("1")
742        {
743            return Err(reqsign_core::Error::config_invalid(
744                "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES must be set to 1 to use executable-sourced external accounts",
745            ));
746        }
747
748        let command = resolve_template(ctx, &source.executable.command)?;
749        let command = command.trim().to_string();
750        if command.is_empty() {
751            return Err(reqsign_core::Error::config_invalid(
752                "credential_source.executable.command must not be empty",
753            ));
754        }
755
756        let timeout = match source.executable.timeout_millis {
757            Some(0) => {
758                return Err(reqsign_core::Error::config_invalid(
759                    "credential_source.executable.timeout_millis must be positive",
760                ));
761            }
762            Some(v) => Duration::from_millis(v),
763            None => DEFAULT_EXECUTABLE_TIMEOUT,
764        };
765
766        let output_file = source
767            .executable
768            .output_file
769            .as_deref()
770            .map(|v| resolve_template(ctx, v))
771            .transpose()?;
772
773        Ok((command, timeout, output_file))
774    }
775
776    fn parse_executable_response(
777        &self,
778        ctx: &Context,
779        body: &[u8],
780        require_expiration: bool,
781        require_unexpired: bool,
782    ) -> Result<ExecutableSubjectToken> {
783        let response: ExecutableResponse = serde_json::from_slice(body).map_err(|e| {
784            reqsign_core::Error::unexpected("failed to parse executable response").with_source(e)
785        })?;
786
787        if response.version != EXECUTABLE_RESPONSE_VERSION {
788            return Err(reqsign_core::Error::credential_invalid(format!(
789                "unsupported executable response version: {}",
790                response.version
791            )));
792        }
793
794        if !response.success {
795            let message = match (response.code.as_deref(), response.message.as_deref()) {
796                (Some(code), Some(message)) => {
797                    format!("executable credential source failed with code {code}: {message}")
798                }
799                (None, Some(message)) => {
800                    format!("executable credential source failed: {message}")
801                }
802                (Some(code), None) => {
803                    format!("executable credential source failed with code {code}")
804                }
805                (None, None) => "executable credential source failed".to_string(),
806            };
807            return Err(reqsign_core::Error::credential_invalid(message));
808        }
809
810        let token_type = response.token_type.as_deref().ok_or_else(|| {
811            reqsign_core::Error::credential_invalid(
812                "successful executable response missing token_type",
813            )
814        })?;
815        if !matches!(
816            token_type,
817            TOKEN_TYPE_JWT | TOKEN_TYPE_ID_TOKEN | TOKEN_TYPE_SAML2
818        ) {
819            return Err(reqsign_core::Error::credential_invalid(format!(
820                "unsupported executable response token_type: {token_type}"
821            )));
822        }
823
824        let expected = self.resolved_subject_token_type(ctx)?;
825        if token_type != expected {
826            return Err(reqsign_core::Error::credential_invalid(format!(
827                "executable response token_type {token_type} does not match configured subject_token_type {expected}"
828            )));
829        }
830
831        let token = if token_type == TOKEN_TYPE_SAML2 {
832            response.saml_response.as_deref().ok_or_else(|| {
833                reqsign_core::Error::credential_invalid(
834                    "successful SAML executable response missing saml_response",
835                )
836            })?
837        } else {
838            response.id_token.as_deref().ok_or_else(|| {
839                reqsign_core::Error::credential_invalid(
840                    "successful executable response missing id_token",
841                )
842            })?
843        };
844        let token = token.trim().to_string();
845        if token.is_empty() {
846            return Err(reqsign_core::Error::credential_invalid(
847                "executable response subject token is empty",
848            ));
849        }
850
851        let expires_at = response
852            .expiration_time
853            .map(Timestamp::from_second)
854            .transpose()?;
855
856        if require_expiration && expires_at.is_none() {
857            return Err(reqsign_core::Error::credential_invalid(
858                "executable response missing expiration_time required by output_file",
859            ));
860        }
861
862        if let Some(expires_at) = expires_at {
863            if require_unexpired && Timestamp::now() >= expires_at {
864                return Err(reqsign_core::Error::credential_invalid(
865                    "executable response is expired",
866                ));
867            }
868        }
869
870        Ok(ExecutableSubjectToken { token, expires_at })
871    }
872
873    async fn load_executable_sourced_token(
874        &self,
875        ctx: &Context,
876        source: &external_account::ExecutableSource,
877    ) -> Result<String> {
878        let (command, timeout, output_file) = self.validate_executable_usage(ctx, source)?;
879
880        if let Some(path) = output_file.as_deref() {
881            if let Ok(content) = ctx.file_read(path).await {
882                debug!("loading executable credential response from output file: {path}");
883                let subject = self.parse_executable_response(ctx, &content, true, false)?;
884                if subject
885                    .expires_at
886                    .is_some_and(|expires_at| Timestamp::now() < expires_at)
887                {
888                    return Ok(subject.token);
889                }
890            }
891        }
892
893        let envs = self.build_executable_env(ctx, output_file.as_deref())?;
894        debug!(
895            "executing external account credential command with declared timeout {:?}",
896            timeout
897        );
898        let output = execute_command_with_env(ctx, &command, &envs, timeout).await?;
899        let parsed =
900            self.parse_executable_response(ctx, &output.stdout, output_file.is_some(), true);
901        let subject = if output.success() {
902            parsed?
903        } else {
904            match parsed {
905                Err(err) if err.kind() == reqsign_core::ErrorKind::CredentialInvalid => {
906                    return Err(err);
907                }
908                Ok(_) => {
909                    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
910                    let detail = if stderr.is_empty() {
911                        format!("command exited with status {}", output.status)
912                    } else {
913                        format!("command exited with status {}: {}", output.status, stderr)
914                    };
915                    return Err(reqsign_core::Error::credential_invalid(format!(
916                        "executable credential source failed: {detail}"
917                    )));
918                }
919                Err(_) => {
920                    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
921                    let detail = if stderr.is_empty() {
922                        format!("command exited with status {}", output.status)
923                    } else {
924                        format!("command exited with status {}: {}", output.status, stderr)
925                    };
926                    return Err(reqsign_core::Error::credential_invalid(format!(
927                        "executable credential source failed: {detail}"
928                    )));
929                }
930            }
931        };
932        Ok(subject.token)
933    }
934
935    async fn exchange_sts_token(&self, ctx: &Context, oidc_token: &str) -> Result<Token> {
936        debug!("exchanging OIDC token for STS access token");
937
938        let scope = self.resolve_sts_scope(ctx);
939        let token_url = resolve_template(ctx, &self.config.token_url)?;
940        let audience = resolve_template(ctx, &self.config.audience)?;
941        let subject_token_type = resolve_template(ctx, &self.config.subject_token_type)?;
942
943        let body = Serializer::new(String::new())
944            .append_pair(
945                "grant_type",
946                "urn:ietf:params:oauth:grant-type:token-exchange",
947            )
948            .append_pair(
949                "requested_token_type",
950                "urn:ietf:params:oauth:token-type:access_token",
951            )
952            .append_pair("audience", &audience)
953            .append_pair("scope", &scope)
954            .append_pair("subject_token", oidc_token)
955            .append_pair("subject_token_type", &subject_token_type)
956            .finish();
957
958        let req = http::Request::builder()
959            .method(http::Method::POST)
960            .uri(token_url)
961            .header(ACCEPT, "application/json")
962            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
963            .body(body.into_bytes().into())
964            .map_err(|e| {
965                reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
966            })?;
967
968        let resp = ctx.http_send(req).await?;
969
970        if resp.status() != http::StatusCode::OK {
971            let status = resp.status();
972            error!("Google STS token exchange returned {status}");
973            return Err(
974                reqsign_core::Error::unexpected("Google STS token exchange failed")
975                    .with_context(format!("http_status: {status}")),
976            );
977        }
978
979        let token_resp: StsTokenResponse = serde_json::from_slice(resp.body()).map_err(|e| {
980            reqsign_core::Error::unexpected("failed to parse STS response").with_source(e)
981        })?;
982
983        let expires_at = token_resp
984            .expires_in
985            .map(|expires_in| Timestamp::now() + Duration::from_secs(expires_in));
986
987        Ok(Token {
988            access_token: token_resp.access_token,
989            expires_at,
990        })
991    }
992
993    async fn impersonate_service_account(
994        &self,
995        ctx: &Context,
996        access_token: &str,
997    ) -> Result<Option<Token>> {
998        let Some(url) = &self.config.service_account_impersonation_url else {
999            return Ok(None);
1000        };
1001
1002        debug!("impersonating service account");
1003
1004        let scope = self.resolve_scope(ctx);
1005        let lifetime = self
1006            .config
1007            .service_account_impersonation_lifetime
1008            .unwrap_or(MAX_LIFETIME);
1009
1010        let lifetime = if lifetime.is_zero() {
1011            return Err(reqsign_core::Error::config_invalid(
1012                "service_account_impersonation.token_lifetime_seconds must be positive",
1013            ));
1014        } else {
1015            lifetime.min(MAX_LIFETIME)
1016        };
1017
1018        generate_access_token(ctx, url, access_token, &[scope], None, Some(lifetime))
1019            .await
1020            .map(Some)
1021    }
1022}
1023impl ProvideCredential for ExternalAccountCredentialProvider {
1024    type Credential = Credential;
1025
1026    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
1027        let signer_email = self
1028            .config
1029            .service_account_impersonation_url
1030            .as_deref()
1031            .and_then(|url| parse_service_account_impersonation_url(url).ok());
1032
1033        // Load the explicitly configured subject token.
1034        let subject_token = self.load_subject_token(ctx).await?;
1035
1036        // Exchange for STS token
1037        let sts_token = self.exchange_sts_token(ctx, &subject_token.token).await?;
1038
1039        // Try to impersonate service account if configured
1040        let final_token = if let Some(token) = self
1041            .impersonate_service_account(ctx, &sts_token.access_token)
1042            .await?
1043        {
1044            token
1045        } else {
1046            sts_token
1047        };
1048
1049        let credential = Credential::with_token(final_token);
1050        Ok(Some(match signer_email {
1051            Some(signer_email) => credential.with_signer_email(signer_email),
1052            None => credential,
1053        }))
1054    }
1055}
1056
1057fn resolve_template(ctx: &Context, input: &str) -> Result<String> {
1058    // Google external account credentials commonly contain `${VAR}` placeholders that must be
1059    // substituted using process environment variables (e.g. GitHub Actions OIDC).
1060    let mut out = String::with_capacity(input.len());
1061    let mut rest = input;
1062
1063    loop {
1064        let Some(start) = rest.find("${") else {
1065            out.push_str(rest);
1066            return Ok(out);
1067        };
1068
1069        out.push_str(&rest[..start]);
1070        rest = &rest[start + 2..];
1071
1072        let Some(end) = rest.find('}') else {
1073            return Err(reqsign_core::Error::config_invalid(format!(
1074                "invalid template syntax in value: {input}"
1075            )));
1076        };
1077
1078        let var = &rest[..end];
1079        rest = &rest[end + 1..];
1080
1081        if var.is_empty() {
1082            return Err(reqsign_core::Error::config_invalid(format!(
1083                "empty template variable in value: {input}"
1084            )));
1085        }
1086
1087        let value = ctx.env_var(var).filter(|v| !v.is_empty()).ok_or_else(|| {
1088            reqsign_core::Error::config_invalid(format!(
1089                "missing environment variable {var} required by template: {input}"
1090            ))
1091        })?;
1092        out.push_str(&value);
1093    }
1094}
1095
1096struct ResolvedAwsSource {
1097    region_url: Option<String>,
1098    url: Option<String>,
1099    regional_cred_verification_url: String,
1100    imdsv2_session_token_url: Option<String>,
1101}
1102
1103fn validate_aws_metadata_url(field: &str, value: &str) -> Result<()> {
1104    let uri: http::Uri = value.parse().map_err(|e| {
1105        reqsign_core::Error::config_invalid(format!("{field} is not a valid URI")).with_source(e)
1106    })?;
1107    let host = uri.host().ok_or_else(|| {
1108        reqsign_core::Error::config_invalid(format!("{field} is missing a host: {value}"))
1109    })?;
1110    if !matches!(host, "169.254.169.254" | "fd00:ec2::254") {
1111        return Err(reqsign_core::Error::config_invalid(format!(
1112            "{field} host must be 169.254.169.254 or fd00:ec2::254, got {host}"
1113        )));
1114    }
1115    Ok(())
1116}
1117
1118fn availability_zone_to_region(zone: &str) -> Result<String> {
1119    let mut chars = zone.chars();
1120    let last = chars
1121        .next_back()
1122        .ok_or_else(|| reqsign_core::Error::credential_invalid("AWS availability zone is empty"))?;
1123    if !last.is_ascii_alphabetic() {
1124        return Err(reqsign_core::Error::credential_invalid(format!(
1125            "AWS availability zone must end with an alphabetic suffix, got {zone}"
1126        )));
1127    }
1128    let region = chars.as_str();
1129    if region.is_empty() {
1130        return Err(reqsign_core::Error::credential_invalid(format!(
1131            "failed to derive AWS region from availability zone {zone}"
1132        )));
1133    }
1134    Ok(region.to_string())
1135}
1136
1137async fn fetch_aws_metadata_text(
1138    ctx: &Context,
1139    url: &str,
1140    session_token: Option<&str>,
1141) -> Result<String> {
1142    let mut req = http::Request::builder().method(Method::GET).uri(url);
1143    if let Some(token) = session_token {
1144        req = req.header(AWS_IMDSV2_TOKEN_HEADER, token);
1145    }
1146    let req = req.body(Vec::<u8>::new().into()).map_err(|e| {
1147        reqsign_core::Error::unexpected("failed to build AWS metadata request").with_source(e)
1148    })?;
1149    let resp = ctx.http_send_as_string(req).await?;
1150    if resp.status() != http::StatusCode::OK {
1151        return Err(reqsign_core::Error::unexpected(format!(
1152            "AWS metadata request to {url} failed: {}",
1153            resp.body()
1154        )));
1155    }
1156    Ok(resp.into_body())
1157}
1158
1159fn aws_subject_header_name(name: &str) -> String {
1160    if name.eq_ignore_ascii_case("authorization") {
1161        "Authorization".to_string()
1162    } else {
1163        name.to_string()
1164    }
1165}
1166
1167async fn execute_command_with_env(
1168    ctx: &Context,
1169    command: &str,
1170    envs: &BTreeMap<String, String>,
1171    timeout: Duration,
1172) -> Result<reqsign_core::CommandOutput> {
1173    #[cfg(windows)]
1174    {
1175        let mut script = String::new();
1176        for (k, v) in envs {
1177            script.push_str("set \"");
1178            script.push_str(k);
1179            script.push('=');
1180            script.push_str(&quote_for_cmd_set(v));
1181            script.push_str("\" && ");
1182        }
1183        script.push_str(command);
1184
1185        let args = ["/C", script.as_str()];
1186        tokio::time::timeout(timeout, ctx.command_execute("cmd", &args))
1187            .await
1188            .map_err(|_| {
1189                reqsign_core::Error::credential_invalid(format!(
1190                    "executable credential source timed out after {}ms",
1191                    timeout.as_millis()
1192                ))
1193            })?
1194    }
1195
1196    #[cfg(not(windows))]
1197    {
1198        let mut script = String::new();
1199        for (k, v) in envs {
1200            script.push_str(k);
1201            script.push('=');
1202            script.push_str(&quote_for_sh(v));
1203            script.push(' ');
1204        }
1205        script.push_str("exec ");
1206        script.push_str(command);
1207
1208        let args = ["-c", script.as_str()];
1209        tokio::time::timeout(timeout, ctx.command_execute("sh", &args))
1210            .await
1211            .map_err(|_| {
1212                reqsign_core::Error::credential_invalid(format!(
1213                    "executable credential source timed out after {}ms",
1214                    timeout.as_millis()
1215                ))
1216            })?
1217    }
1218}
1219
1220#[cfg(windows)]
1221fn quote_for_cmd_set(value: &str) -> String {
1222    value.replace('^', "^^").replace('"', "^\"")
1223}
1224
1225#[cfg(not(windows))]
1226fn quote_for_sh(value: &str) -> String {
1227    if value.is_empty() {
1228        return "''".to_string();
1229    }
1230    format!("'{}'", value.replace('\'', "'\"'\"'"))
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236    use bytes::Bytes;
1237    use http::header::{AUTHORIZATION, CONTENT_TYPE};
1238    use reqsign_core::{CommandExecute, CommandOutput, Env, FileRead, HttpSend};
1239    use std::collections::HashMap;
1240    use std::path::PathBuf;
1241    use std::sync::{Arc, Mutex};
1242
1243    #[derive(Debug, Default)]
1244    struct MockEnv {
1245        vars: HashMap<String, String>,
1246    }
1247
1248    impl MockEnv {
1249        fn with_var(mut self, k: &str, v: &str) -> Self {
1250            self.vars.insert(k.to_string(), v.to_string());
1251            self
1252        }
1253    }
1254
1255    impl Env for MockEnv {
1256        fn var(&self, key: &str) -> Option<String> {
1257            self.vars.get(key).cloned()
1258        }
1259
1260        fn vars(&self) -> HashMap<String, String> {
1261            self.vars.clone()
1262        }
1263
1264        fn home_dir(&self) -> Option<PathBuf> {
1265            None
1266        }
1267    }
1268
1269    #[derive(Debug, Default)]
1270    struct MockFileRead {
1271        files: HashMap<String, Vec<u8>>,
1272    }
1273
1274    impl MockFileRead {
1275        fn with_file(mut self, path: &str, content: impl Into<Vec<u8>>) -> Self {
1276            self.files.insert(path.to_string(), content.into());
1277            self
1278        }
1279    }
1280    impl FileRead for MockFileRead {
1281        async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
1282            self.files.get(path).cloned().ok_or_else(|| {
1283                reqsign_core::Error::config_invalid(format!("file not found: {path}"))
1284            })
1285        }
1286    }
1287
1288    #[derive(Debug, Default)]
1289    struct RecordedCommand {
1290        program: Option<String>,
1291        args: Vec<String>,
1292    }
1293
1294    #[derive(Clone, Debug)]
1295    struct MockCommandExecute {
1296        recorded: Arc<Mutex<RecordedCommand>>,
1297        output: CommandOutput,
1298    }
1299
1300    impl MockCommandExecute {
1301        fn success(stdout: impl Into<Vec<u8>>) -> Self {
1302            Self {
1303                recorded: Arc::new(Mutex::new(RecordedCommand::default())),
1304                output: CommandOutput {
1305                    status: 0,
1306                    stdout: stdout.into(),
1307                    stderr: Vec::new(),
1308                },
1309            }
1310        }
1311
1312        fn failure(stderr: impl Into<Vec<u8>>) -> Self {
1313            Self {
1314                recorded: Arc::new(Mutex::new(RecordedCommand::default())),
1315                output: CommandOutput {
1316                    status: 1,
1317                    stdout: Vec::new(),
1318                    stderr: stderr.into(),
1319                },
1320            }
1321        }
1322
1323        fn with_status(
1324            status: i32,
1325            stdout: impl Into<Vec<u8>>,
1326            stderr: impl Into<Vec<u8>>,
1327        ) -> Self {
1328            Self {
1329                recorded: Arc::new(Mutex::new(RecordedCommand::default())),
1330                output: CommandOutput {
1331                    status,
1332                    stdout: stdout.into(),
1333                    stderr: stderr.into(),
1334                },
1335            }
1336        }
1337    }
1338
1339    impl CommandExecute for MockCommandExecute {
1340        async fn command_execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput> {
1341            let mut recorded = self.recorded.lock().expect("lock must succeed");
1342            recorded.program = Some(program.to_string());
1343            recorded.args = args.iter().map(|v| (*v).to_string()).collect();
1344            Ok(self.output.clone())
1345        }
1346    }
1347
1348    #[derive(Debug)]
1349    struct CaptureStsHttpSend {
1350        expected_url: String,
1351        expected_scope: String,
1352        expected_subject_token: String,
1353        expected_audience: String,
1354        expected_subject_token_type: String,
1355        access_token: String,
1356    }
1357    impl HttpSend for CaptureStsHttpSend {
1358        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1359            assert_eq!(req.method(), http::Method::POST);
1360            assert_eq!(req.uri().to_string(), self.expected_url);
1361            assert_eq!(
1362                req.headers()
1363                    .get(CONTENT_TYPE)
1364                    .expect("content-type must exist")
1365                    .to_str()
1366                    .expect("content-type must be valid string"),
1367                "application/x-www-form-urlencoded"
1368            );
1369
1370            let pairs: HashMap<String, String> = form_urlencoded::parse(req.body().as_ref())
1371                .into_owned()
1372                .collect();
1373            assert_eq!(
1374                pairs.get("grant_type").map(String::as_str),
1375                Some("urn:ietf:params:oauth:grant-type:token-exchange")
1376            );
1377            assert_eq!(
1378                pairs.get("requested_token_type").map(String::as_str),
1379                Some("urn:ietf:params:oauth:token-type:access_token")
1380            );
1381            assert_eq!(
1382                pairs.get("audience").map(String::as_str),
1383                Some(self.expected_audience.as_str())
1384            );
1385            assert_eq!(
1386                pairs.get("scope").map(String::as_str),
1387                Some(self.expected_scope.as_str())
1388            );
1389            assert_eq!(
1390                pairs.get("subject_token").map(String::as_str),
1391                Some(self.expected_subject_token.as_str())
1392            );
1393            assert_eq!(
1394                pairs.get("subject_token_type").map(String::as_str),
1395                Some(self.expected_subject_token_type.as_str())
1396            );
1397
1398            let body = serde_json::json!({
1399                "access_token": &self.access_token,
1400                "expires_in": 3600
1401            });
1402            Ok(http::Response::builder()
1403                .status(http::StatusCode::OK)
1404                .body(serde_json::to_vec(&body).expect("json must encode").into())
1405                .expect("response must build"))
1406        }
1407    }
1408
1409    #[derive(Debug)]
1410    struct PanicHttpSend;
1411
1412    impl HttpSend for PanicHttpSend {
1413        async fn http_send(&self, _req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1414            panic!("expired subject token must be rejected before HTTP")
1415        }
1416    }
1417
1418    #[derive(Debug)]
1419    struct EchoErrorHttpSend {
1420        response_body: String,
1421    }
1422
1423    impl HttpSend for EchoErrorHttpSend {
1424        async fn http_send(&self, _req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1425            Ok(http::Response::builder()
1426                .status(http::StatusCode::BAD_REQUEST)
1427                .body(Bytes::from(self.response_body.clone()))
1428                .expect("response must build"))
1429        }
1430    }
1431
1432    /// Captures STS and IAM Credentials scopes used during WIF + impersonation.
1433    #[derive(Clone, Debug, Default)]
1434    struct CaptureStsAndImpersonateHttpSend {
1435        sts_scope: Arc<Mutex<Option<String>>>,
1436        impersonation_scopes: Arc<Mutex<Option<Vec<String>>>>,
1437    }
1438
1439    impl HttpSend for CaptureStsAndImpersonateHttpSend {
1440        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1441            assert_eq!(req.method(), http::Method::POST);
1442            let url = req.uri().to_string();
1443
1444            if url == "https://sts.googleapis.com/v1/token" {
1445                let pairs: HashMap<String, String> = form_urlencoded::parse(req.body().as_ref())
1446                    .into_owned()
1447                    .collect();
1448                *self.sts_scope.lock().expect("lock") = pairs.get("scope").cloned();
1449                let body = serde_json::json!({
1450                    "access_token": "sts-token",
1451                    "expires_in": 3600
1452                });
1453                return Ok(http::Response::builder()
1454                    .status(http::StatusCode::OK)
1455                    .body(serde_json::to_vec(&body).expect("json must encode").into())
1456                    .expect("response must build"));
1457            }
1458
1459            if url.contains("generateAccessToken") {
1460                assert_eq!(
1461                    req.headers()
1462                        .get(AUTHORIZATION)
1463                        .expect("authorization must exist")
1464                        .to_str()
1465                        .expect("authorization must be valid string"),
1466                    "Bearer sts-token"
1467                );
1468                let body: serde_json::Value =
1469                    serde_json::from_slice(req.body()).expect("impersonation body must be json");
1470                let scopes = body
1471                    .get("scope")
1472                    .and_then(|v| v.as_array())
1473                    .expect("scope array")
1474                    .iter()
1475                    .map(|v| v.as_str().expect("scope string").to_string())
1476                    .collect::<Vec<_>>();
1477                *self.impersonation_scopes.lock().expect("lock") = Some(scopes);
1478
1479                let resp = serde_json::json!({
1480                    "accessToken": "impersonated-token",
1481                    "expireTime": "2099-01-01T00:00:00Z"
1482                });
1483                return Ok(http::Response::builder()
1484                    .status(http::StatusCode::OK)
1485                    .body(serde_json::to_vec(&resp).expect("json must encode").into())
1486                    .expect("response must build"));
1487            }
1488
1489            panic!("unexpected URL: {url}");
1490        }
1491    }
1492
1493    #[derive(Debug)]
1494    struct UrlThenStsHttpSend {
1495        expected_get_url: String,
1496        expected_get_auth: String,
1497        expected_post_url: String,
1498        expected_subject_token: String,
1499    }
1500    impl HttpSend for UrlThenStsHttpSend {
1501        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1502            match *req.method() {
1503                http::Method::GET => {
1504                    assert_eq!(req.uri().to_string(), self.expected_get_url);
1505                    assert_eq!(
1506                        req.headers()
1507                            .get(AUTHORIZATION)
1508                            .expect("authorization must exist")
1509                            .to_str()
1510                            .expect("authorization must be valid string"),
1511                        self.expected_get_auth
1512                    );
1513                    Ok(http::Response::builder()
1514                        .status(http::StatusCode::OK)
1515                        .body(b"test-oidc-token".as_slice().into())
1516                        .expect("response must build"))
1517                }
1518                http::Method::POST => {
1519                    assert_eq!(req.uri().to_string(), self.expected_post_url);
1520                    let pairs: HashMap<String, String> =
1521                        form_urlencoded::parse(req.body().as_ref())
1522                            .into_owned()
1523                            .collect();
1524                    assert_eq!(
1525                        pairs.get("subject_token").map(String::as_str),
1526                        Some(self.expected_subject_token.as_str())
1527                    );
1528                    Ok(http::Response::builder()
1529                        .status(http::StatusCode::OK)
1530                        .body(
1531                            br#"{"access_token":"final-token","expires_in":3600}"#
1532                                .as_slice()
1533                                .into(),
1534                        )
1535                        .expect("response must build"))
1536                }
1537                _ => unreachable!("unexpected method"),
1538            }
1539        }
1540    }
1541
1542    #[derive(Clone, Debug)]
1543    struct AwsMetadataHttpSend {
1544        imdsv2_session_token_url: String,
1545        region_url: String,
1546        credentials_url: String,
1547        session_token: String,
1548        region_response: String,
1549        role_name: String,
1550    }
1551
1552    impl HttpSend for AwsMetadataHttpSend {
1553        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1554            let uri = req.uri().to_string();
1555            match (req.method().clone(), uri.as_str()) {
1556                (Method::PUT, url) if url == self.imdsv2_session_token_url => {
1557                    Ok(http::Response::builder()
1558                        .status(http::StatusCode::OK)
1559                        .body(self.session_token.clone().into_bytes().into())
1560                        .expect("response must build"))
1561                }
1562                (Method::GET, url) if url == self.region_url => {
1563                    assert_eq!(
1564                        req.headers()
1565                            .get(AWS_IMDSV2_TOKEN_HEADER)
1566                            .expect("imdsv2 token header must exist")
1567                            .to_str()
1568                            .expect("imdsv2 token header must be valid"),
1569                        self.session_token
1570                    );
1571                    Ok(http::Response::builder()
1572                        .status(http::StatusCode::OK)
1573                        .body(self.region_response.clone().into_bytes().into())
1574                        .expect("response must build"))
1575                }
1576                (Method::GET, url) if url == self.credentials_url => {
1577                    assert_eq!(
1578                        req.headers()
1579                            .get(AWS_IMDSV2_TOKEN_HEADER)
1580                            .expect("imdsv2 token header must exist")
1581                            .to_str()
1582                            .expect("imdsv2 token header must be valid"),
1583                        self.session_token
1584                    );
1585                    Ok(http::Response::builder()
1586                        .status(http::StatusCode::OK)
1587                        .body(self.role_name.clone().into_bytes().into())
1588                        .expect("response must build"))
1589                }
1590                (Method::GET, url)
1591                    if url == format!("{}/{}", self.credentials_url, self.role_name) =>
1592                {
1593                    assert_eq!(
1594                        req.headers()
1595                            .get(AWS_IMDSV2_TOKEN_HEADER)
1596                            .expect("imdsv2 token header must exist")
1597                            .to_str()
1598                            .expect("imdsv2 token header must be valid"),
1599                        self.session_token
1600                    );
1601                    let body = serde_json::json!({
1602                        "AccessKeyId": "metadata-access-key",
1603                        "SecretAccessKey": "metadata-secret-key",
1604                        "Token": "metadata-session-token"
1605                    });
1606                    Ok(http::Response::builder()
1607                        .status(http::StatusCode::OK)
1608                        .body(serde_json::to_vec(&body).expect("json must encode").into())
1609                        .expect("response must build"))
1610                }
1611                _ => panic!("unexpected AWS metadata request: {} {}", req.method(), uri),
1612            }
1613        }
1614    }
1615
1616    #[test]
1617    fn test_resolve_template() {
1618        let ctx = Context::new().with_env(MockEnv::default().with_var("FOO", "bar"));
1619        assert_eq!(resolve_template(&ctx, "a${FOO}c").unwrap(), "abarc");
1620    }
1621
1622    #[tokio::test]
1623    async fn test_external_account_file_source_uses_form_encoded_sts() -> Result<()> {
1624        let external_account = ExternalAccount {
1625            audience: "aud".to_string(),
1626            subject_token_type: "urn:ietf:params:oauth:token-type:jwt".to_string(),
1627            token_url: "https://sts.googleapis.com/v1/token".to_string(),
1628            credential_source: external_account::Source::File(external_account::FileSource {
1629                file: "/var/run/token".to_string(),
1630                format: external_account::Format::Text,
1631            }),
1632            service_account_impersonation_url: None,
1633            service_account_impersonation: None,
1634        };
1635
1636        let http = CaptureStsHttpSend {
1637            expected_url: "https://sts.googleapis.com/v1/token".to_string(),
1638            expected_scope: "scope-a".to_string(),
1639            expected_subject_token: "test-oidc".to_string(),
1640            expected_audience: "aud".to_string(),
1641            expected_subject_token_type: "urn:ietf:params:oauth:token-type:jwt".to_string(),
1642            access_token: "access-token".to_string(),
1643        };
1644        let fs = MockFileRead::default().with_file("/var/run/token", b"  test-oidc \n");
1645        let ctx = Context::new().with_http_send(http).with_file_read(fs);
1646
1647        let provider =
1648            ExternalAccountCredentialProvider::new(external_account).with_scope("scope-a");
1649        let cred = provider
1650            .provide_credential(&ctx)
1651            .await?
1652            .expect("credential must exist");
1653        assert!(cred.has_token());
1654        assert!(cred.has_valid_token());
1655        Ok(())
1656    }
1657
1658    #[tokio::test]
1659    async fn caller_provided_subject_token_uses_public_exchange_config() -> Result<()> {
1660        let config = ExternalAccountConfig::new(
1661            "aud",
1662            "urn:ietf:params:oauth:token-type:jwt",
1663            "https://sts.googleapis.com/v1/token",
1664        );
1665        let http = CaptureStsHttpSend {
1666            expected_url: "https://sts.googleapis.com/v1/token".to_string(),
1667            expected_scope: "scope-a".to_string(),
1668            expected_subject_token: "caller.subject.token".to_string(),
1669            expected_audience: "aud".to_string(),
1670            expected_subject_token_type: "urn:ietf:params:oauth:token-type:jwt".to_string(),
1671            access_token: "access-token".to_string(),
1672        };
1673        let ctx = Context::new().with_http_send(http);
1674        let provider =
1675            ExternalAccountCredentialProvider::from_subject_token(config, "caller.subject.token")
1676                .with_scope("scope-a");
1677
1678        let credential = provider
1679            .provide_credential(&ctx)
1680            .await?
1681            .expect("credential must exist");
1682        assert!(credential.has_valid_token());
1683        assert!(!format!("{provider:?}").contains("caller.subject.token"));
1684        Ok(())
1685    }
1686
1687    #[tokio::test]
1688    async fn expired_caller_subject_token_is_rejected_before_http() {
1689        let config = ExternalAccountConfig::new(
1690            "aud",
1691            "urn:ietf:params:oauth:token-type:jwt",
1692            "https://sts.googleapis.com/v1/token",
1693        );
1694        let provider = ExternalAccountCredentialProvider::from_subject_token_and_expiration(
1695            config,
1696            "expired.subject.token",
1697            Timestamp::now() - Duration::from_secs(1),
1698        );
1699        let ctx = Context::new().with_http_send(PanicHttpSend);
1700
1701        let err = provider
1702            .provide_credential(&ctx)
1703            .await
1704            .expect_err("expired token must fail");
1705        assert_eq!(err.kind(), reqsign_core::ErrorKind::CredentialInvalid);
1706    }
1707
1708    #[tokio::test]
1709    async fn sts_error_cannot_echo_caller_subject_token() {
1710        let secret = "echoed.google.subject.token";
1711        let config = ExternalAccountConfig::new(
1712            "aud",
1713            "urn:ietf:params:oauth:token-type:jwt",
1714            "https://sts.googleapis.com/v1/token",
1715        );
1716        let provider = ExternalAccountCredentialProvider::from_subject_token(config, secret);
1717        let ctx = Context::new().with_http_send(EchoErrorHttpSend {
1718            response_body: format!(r#"{{"error_description":"{secret}"}}"#),
1719        });
1720
1721        let err = provider
1722            .provide_credential(&ctx)
1723            .await
1724            .expect_err("STS error must be returned");
1725        assert!(!err.to_string().contains(secret));
1726        assert!(!format!("{err:?}").contains(secret));
1727    }
1728
1729    #[tokio::test]
1730    async fn test_external_account_impersonation_uses_iam_scope_for_sts() -> Result<()> {
1731        let impersonation_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@example.com:generateAccessToken";
1732        let business_scope = "https://www.googleapis.com/auth/devstorage.read_write";
1733
1734        let external_account = ExternalAccount {
1735            audience: "aud".to_string(),
1736            subject_token_type: "urn:ietf:params:oauth:token-type:jwt".to_string(),
1737            token_url: "https://sts.googleapis.com/v1/token".to_string(),
1738            credential_source: external_account::Source::File(external_account::FileSource {
1739                file: "/var/run/token".to_string(),
1740                format: external_account::Format::Text,
1741            }),
1742            service_account_impersonation_url: Some(impersonation_url.to_string()),
1743            service_account_impersonation: None,
1744        };
1745
1746        let http = CaptureStsAndImpersonateHttpSend::default();
1747        let sts_scope = Arc::clone(&http.sts_scope);
1748        let impersonation_scopes = Arc::clone(&http.impersonation_scopes);
1749        let fs = MockFileRead::default().with_file("/var/run/token", b"test-oidc");
1750        let ctx = Context::new().with_http_send(http).with_file_read(fs);
1751
1752        let provider =
1753            ExternalAccountCredentialProvider::new(external_account).with_scope(business_scope);
1754        let cred = provider
1755            .provide_credential(&ctx)
1756            .await?
1757            .expect("credential must exist");
1758        assert!(cred.has_valid_token());
1759        assert_eq!(cred.signer_email.as_deref(), Some("sa@example.com"));
1760
1761        assert_eq!(
1762            sts_scope.lock().expect("lock").as_deref(),
1763            Some(STS_IMPERSONATION_SCOPE),
1764            "STS intermediate token must use IAM scope when impersonating"
1765        );
1766        assert_eq!(
1767            impersonation_scopes.lock().expect("lock").as_deref(),
1768            Some([business_scope.to_string()].as_slice()),
1769            "generateAccessToken must request the caller's business scope"
1770        );
1771        Ok(())
1772    }
1773
1774    #[tokio::test]
1775    async fn test_external_account_url_source_supports_env_templates() -> Result<()> {
1776        let external_account = ExternalAccount {
1777            audience: "aud".to_string(),
1778            subject_token_type: "urn:ietf:params:oauth:token-type:jwt".to_string(),
1779            token_url: "https://sts.googleapis.com/v1/token".to_string(),
1780            credential_source: external_account::Source::Url(external_account::UrlSource {
1781                url: "https://example.com/${PATH}".to_string(),
1782                format: external_account::Format::Text,
1783                headers: Some(HashMap::from([(
1784                    "Authorization".to_string(),
1785                    "Bearer ${TOKEN}".to_string(),
1786                )])),
1787            }),
1788            service_account_impersonation_url: None,
1789            service_account_impersonation: None,
1790        };
1791
1792        let http = UrlThenStsHttpSend {
1793            expected_get_url: "https://example.com/oidc".to_string(),
1794            expected_get_auth: "Bearer secret".to_string(),
1795            expected_post_url: "https://sts.googleapis.com/v1/token".to_string(),
1796            expected_subject_token: "test-oidc-token".to_string(),
1797        };
1798
1799        let env = MockEnv::default()
1800            .with_var("PATH", "oidc")
1801            .with_var("TOKEN", "secret");
1802
1803        let ctx = Context::new().with_http_send(http).with_env(env);
1804
1805        let provider = ExternalAccountCredentialProvider::new(external_account);
1806        let cred = provider
1807            .provide_credential(&ctx)
1808            .await?
1809            .expect("credential must exist");
1810        assert!(cred.has_token());
1811        assert!(cred.has_valid_token());
1812        Ok(())
1813    }
1814
1815    fn aws_source() -> external_account::AwsSource {
1816        external_account::AwsSource {
1817            environment_id: "aws1".to_string(),
1818            region_url: Some(
1819                "http://169.254.169.254/latest/meta-data/placement/availability-zone".to_string(),
1820            ),
1821            url: Some(
1822                "http://169.254.169.254/latest/meta-data/iam/security-credentials".to_string(),
1823            ),
1824            regional_cred_verification_url:
1825                "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
1826                    .to_string(),
1827            imdsv2_session_token_url: Some("http://169.254.169.254/latest/api/token".to_string()),
1828        }
1829    }
1830
1831    fn aws_account(source: external_account::AwsSource) -> ExternalAccount {
1832        ExternalAccount {
1833            audience:
1834                "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"
1835                    .to_string(),
1836            subject_token_type: TOKEN_TYPE_AWS4_REQUEST.to_string(),
1837            token_url: "https://sts.googleapis.com/v1/token".to_string(),
1838            credential_source: external_account::Source::Aws(source),
1839            service_account_impersonation_url: None,
1840            service_account_impersonation: None,
1841        }
1842    }
1843
1844    fn parse_aws_subject_token(token: &str) -> serde_json::Value {
1845        serde_json::from_str(token).expect("aws subject token must be valid json")
1846    }
1847
1848    fn header_value<'a>(headers: &'a [serde_json::Value], key: &str) -> Option<&'a str> {
1849        headers.iter().find_map(|entry| {
1850            let current = entry.get("key")?.as_str()?;
1851            if current.eq_ignore_ascii_case(key) {
1852                entry.get("value")?.as_str()
1853            } else {
1854                None
1855            }
1856        })
1857    }
1858
1859    #[tokio::test]
1860    async fn test_aws_source_uses_env_region_and_credentials() -> Result<()> {
1861        let env = MockEnv::default()
1862            .with_var(AWS_REGION, "us-east-1")
1863            .with_var(AWS_ACCESS_KEY_ID, "test-access-key")
1864            .with_var(AWS_SECRET_ACCESS_KEY, "test-secret-key")
1865            .with_var(AWS_SESSION_TOKEN, "test-session-token");
1866        let ctx = Context::new().with_env(env);
1867        let provider = ExternalAccountCredentialProvider::new(aws_account(aws_source()));
1868
1869        let token = provider.load_oidc_token(&ctx).await?;
1870        let json = parse_aws_subject_token(&token);
1871        assert_eq!(json.get("method").and_then(|v| v.as_str()), Some("POST"));
1872        assert_eq!(json.get("body").and_then(|v| v.as_str()), Some(""));
1873        assert_eq!(
1874            json.get("url").and_then(|v| v.as_str()),
1875            Some(
1876                "https://sts.us-east-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15",
1877            )
1878        );
1879        let headers = json
1880            .get("headers")
1881            .and_then(|v| v.as_array())
1882            .expect("headers must be an array");
1883        assert_eq!(
1884            header_value(headers, "x-goog-cloud-target-resource"),
1885            Some(
1886                "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"
1887            )
1888        );
1889        assert_eq!(
1890            header_value(headers, "x-amz-security-token"),
1891            Some("test-session-token")
1892        );
1893        assert!(
1894            header_value(headers, "Authorization")
1895                .expect("authorization header must exist")
1896                .starts_with("AWS4-HMAC-SHA256 Credential=test-access-key/")
1897        );
1898        assert!(header_value(headers, "x-amz-date").is_some());
1899        Ok(())
1900    }
1901
1902    #[tokio::test]
1903    async fn test_aws_source_falls_back_to_metadata_with_imdsv2() -> Result<()> {
1904        let http = AwsMetadataHttpSend {
1905            imdsv2_session_token_url: "http://169.254.169.254/latest/api/token".to_string(),
1906            region_url: "http://169.254.169.254/latest/meta-data/placement/availability-zone"
1907                .to_string(),
1908            credentials_url: "http://169.254.169.254/latest/meta-data/iam/security-credentials"
1909                .to_string(),
1910            session_token: "imdsv2-token".to_string(),
1911            region_response: "us-west-2b".to_string(),
1912            role_name: "test-role".to_string(),
1913        };
1914        let ctx = Context::new().with_http_send(http);
1915        let provider = ExternalAccountCredentialProvider::new(aws_account(aws_source()));
1916
1917        let token = provider.load_oidc_token(&ctx).await?;
1918        let json = parse_aws_subject_token(&token);
1919        assert_eq!(
1920            json.get("url").and_then(|v| v.as_str()),
1921            Some(
1922                "https://sts.us-west-2.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15",
1923            )
1924        );
1925        assert_eq!(json.get("body").and_then(|v| v.as_str()), Some(""));
1926        let headers = json
1927            .get("headers")
1928            .and_then(|v| v.as_array())
1929            .expect("headers must be an array");
1930        assert_eq!(
1931            header_value(headers, "x-amz-security-token"),
1932            Some("metadata-session-token")
1933        );
1934        assert!(
1935            header_value(headers, "Authorization")
1936                .expect("authorization header must exist")
1937                .starts_with("AWS4-HMAC-SHA256 Credential=metadata-access-key/")
1938        );
1939        Ok(())
1940    }
1941
1942    #[tokio::test]
1943    async fn test_aws_source_rejects_unsupported_environment_id() {
1944        let mut source = aws_source();
1945        source.environment_id = "aws2".to_string();
1946
1947        let provider = ExternalAccountCredentialProvider::new(aws_account(source));
1948        let err = provider
1949            .load_oidc_token(&Context::new())
1950            .await
1951            .expect_err("unsupported AWS environment_id must fail");
1952        assert!(err.to_string().contains("aws2"));
1953    }
1954
1955    #[tokio::test]
1956    async fn test_aws_source_rejects_invalid_metadata_host() {
1957        let mut source = aws_source();
1958        source.region_url =
1959            Some("http://example.com/latest/meta-data/placement/availability-zone".to_string());
1960
1961        let provider = ExternalAccountCredentialProvider::new(aws_account(source));
1962        let err = provider
1963            .load_oidc_token(&Context::new())
1964            .await
1965            .expect_err("invalid metadata host must fail");
1966        assert!(err.to_string().contains("169.254.169.254"));
1967    }
1968
1969    fn executable_source(
1970        command: &str,
1971        output_file: Option<&str>,
1972    ) -> external_account::ExecutableSource {
1973        external_account::ExecutableSource {
1974            executable: external_account::ExecutableConfig {
1975                command: command.to_string(),
1976                timeout_millis: Some(5000),
1977                output_file: output_file.map(|v| v.to_string()),
1978            },
1979        }
1980    }
1981
1982    fn executable_account(
1983        source: external_account::ExecutableSource,
1984        subject_token_type: &str,
1985    ) -> ExternalAccount {
1986        ExternalAccount {
1987            audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider".to_string(),
1988            subject_token_type: subject_token_type.to_string(),
1989            token_url: "https://sts.googleapis.com/v1/token".to_string(),
1990            credential_source: external_account::Source::Executable(source),
1991            service_account_impersonation_url: Some(
1992                "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test%40example.com:generateAccessToken"
1993                    .to_string(),
1994            ),
1995            service_account_impersonation: None,
1996        }
1997    }
1998
1999    #[tokio::test]
2000    async fn test_executable_source_uses_cached_output_file() -> Result<()> {
2001        let external_account = executable_account(
2002            executable_source("/bin/example --flag", Some("/tmp/exec-cache.json")),
2003            TOKEN_TYPE_ID_TOKEN,
2004        );
2005        let cache = serde_json::json!({
2006            "version": 1,
2007            "success": true,
2008            "token_type": TOKEN_TYPE_ID_TOKEN,
2009            "id_token": "cached-token",
2010            "expiration_time": Timestamp::now().as_second() + 3600,
2011        });
2012        let fs = MockFileRead::default().with_file(
2013            "/tmp/exec-cache.json",
2014            serde_json::to_vec(&cache).expect("json"),
2015        );
2016        let ctx = Context::new()
2017            .with_file_read(fs)
2018            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2019            .with_command_execute(MockCommandExecute::success(br#"{"unexpected":true}"#));
2020
2021        let provider = ExternalAccountCredentialProvider::new(external_account);
2022        let token = provider.load_oidc_token(&ctx).await?;
2023        assert_eq!(token, "cached-token");
2024        Ok(())
2025    }
2026
2027    #[tokio::test]
2028    async fn test_executable_source_runs_command_with_required_env() -> Result<()> {
2029        let external_account = executable_account(
2030            executable_source(
2031                "/bin/example --arg=value",
2032                Some("/tmp/cache-${SUFFIX}.json"),
2033            ),
2034            TOKEN_TYPE_ID_TOKEN,
2035        );
2036        let command = MockCommandExecute::success(
2037            serde_json::to_vec(&serde_json::json!({
2038                "version": 1,
2039                "success": true,
2040                "token_type": TOKEN_TYPE_ID_TOKEN,
2041                "id_token": "exec-token",
2042                "expiration_time": Timestamp::now().as_second() + 3600,
2043            }))
2044            .expect("json"),
2045        );
2046        let recorded = command.recorded.clone();
2047        let env = MockEnv::default()
2048            .with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")
2049            .with_var("SUFFIX", "value");
2050        let ctx = Context::new().with_env(env).with_command_execute(command);
2051
2052        let provider = ExternalAccountCredentialProvider::new(external_account);
2053        let token = provider.load_oidc_token(&ctx).await?;
2054        assert_eq!(token, "exec-token");
2055
2056        let recorded = recorded.lock().expect("lock must succeed");
2057        #[cfg(windows)]
2058        {
2059            assert_eq!(recorded.program.as_deref(), Some("cmd"));
2060            let script = recorded.args.get(1).expect("cmd script must exist");
2061            assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE="));
2062            assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE="));
2063            assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=test@example.com"));
2064            assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=/tmp/cache-value.json"));
2065            assert!(script.contains("/bin/example --arg=value"));
2066        }
2067        #[cfg(not(windows))]
2068        {
2069            assert_eq!(recorded.program.as_deref(), Some("sh"));
2070            let script = recorded.args.get(1).expect("sh script must exist");
2071            assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE='//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider'"));
2072            assert!(script.contains(
2073                "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE='urn:ietf:params:oauth:token-type:id_token'"
2074            ));
2075            assert!(
2076                script.contains("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL='test@example.com'")
2077            );
2078            assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE='/tmp/cache-value.json'"));
2079            assert!(script.ends_with("exec /bin/example --arg=value"));
2080        }
2081        Ok(())
2082    }
2083
2084    #[tokio::test]
2085    async fn test_executable_source_requires_opt_in() {
2086        let external_account =
2087            executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN);
2088        let ctx = Context::new().with_command_execute(MockCommandExecute::success(Vec::new()));
2089
2090        let provider = ExternalAccountCredentialProvider::new(external_account);
2091        let err = provider
2092            .load_oidc_token(&ctx)
2093            .await
2094            .expect_err("missing opt-in must fail");
2095        assert!(
2096            err.to_string()
2097                .contains("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES")
2098        );
2099    }
2100
2101    #[tokio::test]
2102    async fn test_executable_source_rejects_error_response() {
2103        let external_account =
2104            executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN);
2105        let command = MockCommandExecute::with_status(
2106            1,
2107            serde_json::to_vec(&serde_json::json!({
2108                "version": 1,
2109                "success": false,
2110                "code": "401",
2111                "message": "Caller not authorized.",
2112            }))
2113            .expect("json"),
2114            b"permission denied".as_slice(),
2115        );
2116        let ctx = Context::new()
2117            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2118            .with_command_execute(command);
2119
2120        let provider = ExternalAccountCredentialProvider::new(external_account);
2121        let err = provider
2122            .load_oidc_token(&ctx)
2123            .await
2124            .expect_err("error response must fail");
2125        assert!(err.to_string().contains("Caller not authorized"));
2126    }
2127
2128    #[derive(Clone, Debug)]
2129    struct SlowCommandExecute;
2130
2131    impl CommandExecute for SlowCommandExecute {
2132        async fn command_execute(&self, _program: &str, _args: &[&str]) -> Result<CommandOutput> {
2133            tokio::time::sleep(Duration::from_millis(20)).await;
2134            Ok(CommandOutput {
2135                status: 0,
2136                stdout: br#"{"version":1,"success":true,"token_type":"urn:ietf:params:oauth:token-type:id_token","id_token":"slow-token"}"#.to_vec(),
2137                stderr: Vec::new(),
2138            })
2139        }
2140    }
2141
2142    #[tokio::test]
2143    async fn test_executable_source_honors_timeout() {
2144        let source = external_account::ExecutableSource {
2145            executable: external_account::ExecutableConfig {
2146                command: "/bin/example".to_string(),
2147                timeout_millis: Some(1),
2148                output_file: None,
2149            },
2150        };
2151        let external_account = executable_account(source, TOKEN_TYPE_ID_TOKEN);
2152        let ctx = Context::new()
2153            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2154            .with_command_execute(SlowCommandExecute);
2155
2156        let provider = ExternalAccountCredentialProvider::new(external_account);
2157        let err = provider
2158            .load_oidc_token(&ctx)
2159            .await
2160            .expect_err("slow executable must time out");
2161        assert!(err.to_string().contains("timed out"));
2162    }
2163
2164    #[tokio::test]
2165    async fn test_executable_source_rejects_non_zero_exit() {
2166        let external_account =
2167            executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN);
2168        let command = MockCommandExecute::failure("permission denied");
2169        let ctx = Context::new()
2170            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2171            .with_command_execute(command);
2172
2173        let provider = ExternalAccountCredentialProvider::new(external_account);
2174        let err = provider
2175            .load_oidc_token(&ctx)
2176            .await
2177            .expect_err("non-zero exit must fail");
2178        assert!(!err.to_string().is_empty());
2179    }
2180
2181    #[tokio::test]
2182    async fn test_executable_source_rejects_token_type_mismatch() {
2183        let external_account =
2184            executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN);
2185        let command = MockCommandExecute::success(
2186            serde_json::to_vec(&serde_json::json!({
2187                "version": 1,
2188                "success": true,
2189                "token_type": TOKEN_TYPE_SAML2,
2190                "saml_response": "response",
2191            }))
2192            .expect("json"),
2193        );
2194        let ctx = Context::new()
2195            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2196            .with_command_execute(command);
2197
2198        let provider = ExternalAccountCredentialProvider::new(external_account);
2199        let err = provider
2200            .load_oidc_token(&ctx)
2201            .await
2202            .expect_err("mismatched token type must fail");
2203        assert!(err.to_string().contains("does not match"));
2204    }
2205
2206    #[tokio::test]
2207    async fn test_executable_source_requires_expiration_for_output_file() {
2208        let external_account = executable_account(
2209            executable_source("/bin/example", Some("/tmp/cache.json")),
2210            TOKEN_TYPE_ID_TOKEN,
2211        );
2212        let command = MockCommandExecute::success(
2213            serde_json::to_vec(&serde_json::json!({
2214                "version": 1,
2215                "success": true,
2216                "token_type": TOKEN_TYPE_ID_TOKEN,
2217                "id_token": "token",
2218            }))
2219            .expect("json"),
2220        );
2221        let ctx = Context::new()
2222            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2223            .with_command_execute(command);
2224
2225        let provider = ExternalAccountCredentialProvider::new(external_account);
2226        let err = provider
2227            .load_oidc_token(&ctx)
2228            .await
2229            .expect_err("missing expiration must fail");
2230        assert!(err.to_string().contains("expiration_time"));
2231    }
2232
2233    #[tokio::test]
2234    async fn test_executable_source_rejects_expired_cached_output() -> Result<()> {
2235        let external_account = executable_account(
2236            executable_source("/bin/example", Some("/tmp/cache.json")),
2237            TOKEN_TYPE_ID_TOKEN,
2238        );
2239        let cache = serde_json::json!({
2240            "version": 1,
2241            "success": true,
2242            "token_type": TOKEN_TYPE_ID_TOKEN,
2243            "id_token": "cached-token",
2244            "expiration_time": Timestamp::now().as_second() - 1,
2245        });
2246        let fs = MockFileRead::default()
2247            .with_file("/tmp/cache.json", serde_json::to_vec(&cache).expect("json"));
2248        let command = MockCommandExecute::success(
2249            serde_json::to_vec(&serde_json::json!({
2250                "version": 1,
2251                "success": true,
2252                "token_type": TOKEN_TYPE_ID_TOKEN,
2253                "id_token": "fresh-token",
2254                "expiration_time": Timestamp::now().as_second() + 3600,
2255            }))
2256            .expect("json"),
2257        );
2258        let ctx = Context::new()
2259            .with_file_read(fs)
2260            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2261            .with_command_execute(command);
2262
2263        let provider = ExternalAccountCredentialProvider::new(external_account);
2264        let token = provider.load_oidc_token(&ctx).await?;
2265        assert_eq!(token, "fresh-token");
2266        Ok(())
2267    }
2268
2269    #[tokio::test]
2270    async fn test_executable_source_rejects_invalid_cached_output() {
2271        let external_account = executable_account(
2272            executable_source("/bin/example", Some("/tmp/cache.json")),
2273            TOKEN_TYPE_ID_TOKEN,
2274        );
2275        let fs = MockFileRead::default().with_file("/tmp/cache.json", b"{invalid json");
2276        let command = MockCommandExecute::success(
2277            serde_json::to_vec(&serde_json::json!({
2278                "version": 1,
2279                "success": true,
2280                "token_type": TOKEN_TYPE_ID_TOKEN,
2281                "id_token": "fresh-token",
2282                "expiration_time": Timestamp::now().as_second() + 3600,
2283            }))
2284            .expect("json"),
2285        );
2286        let ctx = Context::new()
2287            .with_file_read(fs)
2288            .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1"))
2289            .with_command_execute(command);
2290
2291        let provider = ExternalAccountCredentialProvider::new(external_account);
2292        let err = provider
2293            .load_oidc_token(&ctx)
2294            .await
2295            .expect_err("invalid cache must fail");
2296        assert!(
2297            err.to_string()
2298                .contains("failed to parse executable response")
2299        );
2300    }
2301}