1use 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
39const MAX_LIFETIME: Duration = Duration::from_secs(3600);
41const DEFAULT_EXECUTABLE_TIMEOUT: Duration = Duration::from_secs(30);
43const 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";
67const STS_IMPERSONATION_SCOPE: &str = "https://www.googleapis.com/auth/iam";
73
74#[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#[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 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 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 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#[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 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 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 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 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 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, ®ion, 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 let subject_token = self.load_subject_token(ctx).await?;
1035
1036 let sts_token = self.exchange_sts_token(ctx, &subject_token.token).await?;
1038
1039 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 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("e_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("e_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 #[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}