1use std::fmt::{self, Debug, Formatter};
19use std::sync::Arc;
20use std::time::Duration;
21
22use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
23use percent_encoding::utf8_percent_encode;
24use reqsign_core::time::Timestamp;
25use reqsign_core::{
26 Context, Error, ErrorKind, GrantCredential, ProvideCredential, ProvideCredentialDyn, Result,
27 SigningCredential,
28};
29use serde::{Deserialize, Serialize};
30
31use crate::constants::{GOOG_URI_ENCODE_SET, TOKEN_OPERATION_HEADROOM};
32use crate::{Credential, Token};
33
34const IAM_CREDENTIALS_ENDPOINT: &str = "https://iamcredentials.googleapis.com";
35const IAM_CREDENTIALS_REQUEST_HEADROOM: Duration = Duration::from_secs(10);
36const MAX_ACCESS_TOKEN_LIFETIME: Duration = Duration::from_secs(43_200);
37
38#[derive(Clone)]
50pub struct ServiceAccountImpersonationGrant {
51 target_service_account_email: String,
52 scopes: Vec<String>,
53 delegates: Vec<String>,
54}
55
56impl Debug for ServiceAccountImpersonationGrant {
57 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
58 f.debug_struct("ServiceAccountImpersonationGrant")
59 .finish_non_exhaustive()
60 }
61}
62
63impl ServiceAccountImpersonationGrant {
64 pub fn new(
66 target_service_account_email: impl Into<String>,
67 scopes: impl IntoIterator<Item = impl Into<String>>,
68 ) -> Self {
69 Self {
70 target_service_account_email: target_service_account_email.into(),
71 scopes: scopes.into_iter().map(Into::into).collect(),
72 delegates: Vec::new(),
73 }
74 }
75
76 pub fn with_delegate(mut self, delegate: impl Into<String>) -> Self {
78 self.delegates.push(delegate.into());
79 self
80 }
81
82 pub fn with_delegates(
84 mut self,
85 delegates: impl IntoIterator<Item = impl Into<String>>,
86 ) -> Self {
87 self.delegates.extend(delegates.into_iter().map(Into::into));
88 self
89 }
90
91 fn validate(&self) -> Result<(String, Vec<String>)> {
92 if !is_service_account_email(&self.target_service_account_email) {
93 return Err(Error::request_invalid(
94 "Google service-account impersonation target must be a valid service-account email",
95 ));
96 }
97 if self.scopes.is_empty() {
98 return Err(Error::request_invalid(
99 "Google service-account impersonation requires at least one OAuth scope",
100 ));
101 }
102 if self.scopes.iter().any(|scope| !is_valid_scope(scope)) {
103 return Err(Error::request_invalid(
104 "Google service-account impersonation scopes must be non-empty and contain no whitespace or control characters",
105 ));
106 }
107
108 let delegates = self
109 .delegates
110 .iter()
111 .map(|delegate| {
112 if !is_service_account_email(delegate)
113 && !(delegate.bytes().all(|byte| byte.is_ascii_digit())
114 && !delegate.is_empty())
115 {
116 return Err(Error::request_invalid(
117 "Google service-account impersonation delegates must be service-account emails or numeric service-account IDs",
118 ));
119 }
120 Ok(format!("projects/-/serviceAccounts/{delegate}"))
121 })
122 .collect::<Result<Vec<_>>>()?;
123
124 let encoded_target =
125 utf8_percent_encode(&self.target_service_account_email, &GOOG_URI_ENCODE_SET);
126 Ok((
127 format!(
128 "{IAM_CREDENTIALS_ENDPOINT}/v1/projects/-/serviceAccounts/{encoded_target}:generateAccessToken"
129 ),
130 delegates,
131 ))
132 }
133}
134
135#[derive(Clone)]
185pub struct ServiceAccountImpersonationGranter {
186 grant: ServiceAccountImpersonationGrant,
187 #[cfg(test)]
188 now: Option<Timestamp>,
189 #[cfg(test)]
190 time_after_request: Option<Timestamp>,
191}
192
193impl Debug for ServiceAccountImpersonationGranter {
194 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
195 f.debug_struct("ServiceAccountImpersonationGranter")
196 .finish_non_exhaustive()
197 }
198}
199
200impl ServiceAccountImpersonationGranter {
201 pub fn new(grant: ServiceAccountImpersonationGrant) -> Self {
203 Self {
204 grant,
205 #[cfg(test)]
206 now: None,
207 #[cfg(test)]
208 time_after_request: None,
209 }
210 }
211
212 pub fn with_grant(mut self, grant: ServiceAccountImpersonationGrant) -> Self {
214 self.grant = grant;
215 self
216 }
217
218 fn now(&self) -> Timestamp {
219 #[cfg(test)]
220 if let Some(now) = self.now {
221 return now;
222 }
223 Timestamp::now()
224 }
225
226 fn time_after_request(&self) -> Timestamp {
227 #[cfg(test)]
228 if let Some(now) = self.time_after_request {
229 return now;
230 }
231 #[cfg(test)]
232 if let Some(now) = self.now {
233 return now;
234 }
235 Timestamp::now()
236 }
237
238 #[cfg(test)]
239 fn with_time(mut self, now: Timestamp) -> Self {
240 self.now = Some(now);
241 self.time_after_request = Some(now);
242 self
243 }
244
245 #[cfg(test)]
246 fn with_time_after_request(mut self, now: Timestamp) -> Self {
247 self.time_after_request = Some(now);
248 self
249 }
250
251 fn source_token<'a>(
252 &self,
253 credential: &'a Credential,
254 required_until: Timestamp,
255 ) -> Result<&'a Token> {
256 if credential.service_account.is_some() {
257 return Err(Error::credential_invalid(
258 "Google service-account impersonation requires a token-only source credential",
259 ));
260 }
261 let token = credential.token.as_ref().ok_or_else(|| {
262 Error::credential_invalid(
263 "Google service-account impersonation requires an OAuth access token",
264 )
265 })?;
266 if !is_valid_access_token(&token.access_token) {
267 return Err(Error::credential_invalid(
268 "Google service-account impersonation source access token is empty or malformed",
269 ));
270 }
271 if token.expires_at.is_none() {
272 return Err(Error::credential_invalid(
273 "Google service-account impersonation source token expiration is required",
274 ));
275 }
276 if !token.is_valid_at(required_until) {
277 return Err(Error::credential_invalid(
278 "source OAuth access token expires before Google service-account impersonation can complete",
279 ));
280 }
281 Ok(token)
282 }
283
284 fn validate_lifetime(expires_in: Option<Duration>) -> Result<()> {
285 let Some(expires_in) = expires_in else {
286 return Ok(());
287 };
288 if expires_in <= TOKEN_OPERATION_HEADROOM || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
289 return Err(Error::request_invalid(
290 "Google service-account impersonation lifetime must be greater than 10 seconds and at most 43200 seconds",
291 ));
292 }
293 Ok(())
294 }
295}
296
297impl GrantCredential for ServiceAccountImpersonationGranter {
298 type Credential = Credential;
299
300 fn required_valid_until(
301 &self,
302 _credential: &Self::Credential,
303 _expires_in: Option<Duration>,
304 ) -> Timestamp {
305 self.now() + IAM_CREDENTIALS_REQUEST_HEADROOM
306 }
307
308 async fn grant_credential(
309 &self,
310 ctx: &Context,
311 credential: &Self::Credential,
312 expires_in: Option<Duration>,
313 ) -> Result<Self::Credential> {
314 Self::validate_lifetime(expires_in)?;
315 let (endpoint, delegates) = self.grant.validate()?;
316 let required_until = self.required_valid_until(credential, expires_in);
317 let source = self.source_token(credential, required_until)?;
318 let token = generate_access_token(
319 ctx,
320 &endpoint,
321 &source.access_token,
322 &self.grant.scopes,
323 Some(&delegates),
324 expires_in,
325 )
326 .await?;
327
328 let response_time = self.time_after_request();
329 if !source.is_valid_at(response_time) {
330 return Err(Error::credential_invalid(
331 "source OAuth access token expired during Google service-account impersonation",
332 ));
333 }
334 if !token.is_valid_at(response_time + TOKEN_OPERATION_HEADROOM) {
335 return Err(Error::credential_invalid(
336 "impersonated access token is not valid long enough for Google signing",
337 ));
338 }
339
340 Ok(Credential::with_token(token)
341 .with_signer_email(self.grant.target_service_account_email.clone()))
342 }
343}
344
345#[derive(Clone)]
352pub struct ServiceAccountImpersonationCredentialProvider {
353 source: Arc<dyn ProvideCredentialDyn<Credential = Credential>>,
354 granter: ServiceAccountImpersonationGranter,
355 lifetime: Option<Duration>,
356}
357
358impl Debug for ServiceAccountImpersonationCredentialProvider {
359 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
360 f.debug_struct("ServiceAccountImpersonationCredentialProvider")
361 .finish_non_exhaustive()
362 }
363}
364
365impl ServiceAccountImpersonationCredentialProvider {
366 pub fn new(
368 source: impl ProvideCredential<Credential = Credential>,
369 grant: ServiceAccountImpersonationGrant,
370 ) -> Self {
371 Self {
372 source: Arc::new(source),
373 granter: ServiceAccountImpersonationGranter::new(grant),
374 lifetime: None,
375 }
376 }
377
378 pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
380 self.lifetime = Some(lifetime);
381 self
382 }
383
384 pub fn with_grant(mut self, grant: ServiceAccountImpersonationGrant) -> Self {
386 self.granter = self.granter.with_grant(grant);
387 self
388 }
389}
390
391impl ProvideCredential for ServiceAccountImpersonationCredentialProvider {
392 type Credential = Credential;
393
394 async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
395 let Some(source) = self.source.provide_credential_dyn(ctx).await? else {
396 return Ok(None);
397 };
398 self.granter
399 .grant_credential(ctx, &source, self.lifetime)
400 .await
401 .map(Some)
402 }
403}
404
405#[derive(Serialize)]
406struct GenerateAccessTokenRequest<'a> {
407 scope: &'a [String],
408 #[serde(skip_serializing_if = "Option::is_none")]
409 delegates: Option<&'a [String]>,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 lifetime: Option<String>,
412}
413
414#[derive(Deserialize)]
415#[serde(rename_all = "camelCase")]
416struct GenerateAccessTokenResponse {
417 access_token: String,
418 expire_time: String,
419}
420
421fn format_lifetime(lifetime: Duration) -> String {
422 let seconds = lifetime.as_secs();
423 let nanoseconds = lifetime.subsec_nanos();
424 if nanoseconds == 0 {
425 return format!("{seconds}s");
426 }
427
428 let mut fraction = format!("{nanoseconds:09}");
429 while fraction.ends_with('0') {
430 fraction.pop();
431 }
432 format!("{seconds}.{fraction}s")
433}
434
435pub(crate) async fn generate_access_token(
436 ctx: &Context,
437 endpoint: &str,
438 source_access_token: &str,
439 scopes: &[String],
440 delegates: Option<&[String]>,
441 lifetime: Option<Duration>,
442) -> Result<Token> {
443 let request = GenerateAccessTokenRequest {
444 scope: scopes,
445 delegates,
446 lifetime: lifetime.map(format_lifetime),
447 };
448 let body = serde_json::to_vec(&request).map_err(|err| {
449 Error::unexpected("failed to serialize IAM Credentials request").with_source(err)
450 })?;
451 let mut authorization = format!("Bearer {source_access_token}")
452 .parse::<http::HeaderValue>()
453 .map_err(|_| {
454 Error::credential_invalid("source OAuth access token is not a valid HTTP header value")
455 })?;
456 authorization.set_sensitive(true);
457 let request = http::Request::builder()
458 .method(http::Method::POST)
459 .uri(endpoint)
460 .header(ACCEPT, "application/json")
461 .header(CONTENT_TYPE, "application/json")
462 .header(AUTHORIZATION, authorization)
463 .body(body.into())
464 .map_err(|err| {
465 Error::unexpected("failed to build IAM Credentials request").with_source(err)
466 })?;
467 let response = ctx.http_send(request).await.map_err(|err| {
468 Error::new(
469 err.kind(),
470 "IAM Credentials generateAccessToken request failed",
471 )
472 .set_retryable(err.is_retryable())
473 })?;
474 if response.status() != http::StatusCode::OK {
475 return Err(iam_credentials_error(response.status()));
476 }
477
478 let response: GenerateAccessTokenResponse = serde_json::from_slice(response.body())
479 .map_err(|_| Error::unexpected("failed to parse IAM Credentials response"))?;
480 if !is_valid_access_token(&response.access_token) {
481 return Err(Error::unexpected(
482 "IAM Credentials response access token is missing or malformed",
483 ));
484 }
485 let expires_at = response.expire_time.parse::<Timestamp>().map_err(|_| {
486 Error::unexpected("IAM Credentials response contains an invalid expiration")
487 })?;
488
489 Ok(Token {
490 access_token: response.access_token,
491 expires_at: Some(expires_at),
492 })
493}
494
495fn iam_credentials_error(status: http::StatusCode) -> Error {
496 let error = match status {
497 http::StatusCode::UNAUTHORIZED => {
498 Error::credential_invalid("IAM Credentials rejected the source credential")
499 }
500 http::StatusCode::FORBIDDEN => {
501 Error::permission_denied("IAM Credentials denied service-account impersonation")
502 }
503 http::StatusCode::TOO_MANY_REQUESTS => {
504 Error::rate_limited("IAM Credentials rate limit exceeded")
505 }
506 status if status.is_client_error() => {
507 Error::request_invalid("IAM Credentials rejected the impersonation request")
508 }
509 _ => Error::new(
510 ErrorKind::Unexpected,
511 "IAM Credentials returned an unexpected response",
512 )
513 .set_retryable(status.is_server_error()),
514 };
515 error.with_context(format!("http_status: {}", status.as_u16()))
516}
517
518fn is_service_account_email(value: &str) -> bool {
519 let Some((local, domain)) = value.split_once('@') else {
520 return false;
521 };
522 !local.is_empty()
523 && !domain.is_empty()
524 && !domain.contains('@')
525 && value.is_ascii()
526 && local
527 .bytes()
528 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
529 && domain
530 .bytes()
531 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
532 && domain.ends_with(".gserviceaccount.com")
533}
534
535fn is_valid_scope(value: &str) -> bool {
536 !value.is_empty()
537 && !value
538 .chars()
539 .any(|value| value.is_whitespace() || value.is_control())
540}
541
542fn is_valid_access_token(value: &str) -> bool {
543 !value.is_empty()
544 && value.is_ascii()
545 && !value
546 .bytes()
547 .any(|value| value.is_ascii_whitespace() || value.is_ascii_control())
548}
549
550#[cfg(test)]
551mod tests {
552 use std::collections::VecDeque;
553 use std::sync::atomic::{AtomicUsize, Ordering};
554 use std::sync::{Arc, Mutex};
555
556 use bytes::Bytes;
557 use reqsign_core::{ErrorKind, Granter, HttpSend};
558 use serde_json::json;
559
560 use super::*;
561 use crate::ServiceAccount;
562
563 #[derive(Clone, Debug)]
564 struct MockHttpSend {
565 responses: Arc<Mutex<VecDeque<http::Response<Bytes>>>>,
566 requests: Arc<Mutex<Vec<http::Request<Bytes>>>>,
567 }
568
569 impl MockHttpSend {
570 fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
571 Self {
572 responses: Arc::new(Mutex::new(responses.into_iter().collect())),
573 requests: Arc::new(Mutex::new(Vec::new())),
574 }
575 }
576
577 fn request_count(&self) -> usize {
578 self.requests.lock().expect("lock poisoned").len()
579 }
580 }
581
582 impl HttpSend for MockHttpSend {
583 async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
584 self.requests.lock().expect("lock poisoned").push(request);
585 self.responses
586 .lock()
587 .expect("lock poisoned")
588 .pop_front()
589 .ok_or_else(|| Error::unexpected("unexpected HTTP request"))
590 }
591 }
592
593 #[derive(Clone, Debug)]
594 struct CountingProvider {
595 calls: Arc<AtomicUsize>,
596 credential: Option<Credential>,
597 }
598
599 impl ProvideCredential for CountingProvider {
600 type Credential = Credential;
601
602 async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
603 self.calls.fetch_add(1, Ordering::SeqCst);
604 Ok(self.credential.clone())
605 }
606 }
607
608 fn timestamp(value: &str) -> Timestamp {
609 value.parse().expect("timestamp must parse")
610 }
611
612 #[test]
613 fn parses_redacted_real_generate_access_token_response() {
614 let response: GenerateAccessTokenResponse = serde_json::from_slice(include_bytes!(
615 "../tests/fixtures/iam_generate_access_token_response.json"
616 ))
617 .expect("real IAM Credentials response fixture must parse");
618
619 assert_eq!(response.access_token, "REDACTED");
620 assert_eq!(response.expire_time, "2026-09-03T13:30:38Z");
621 }
622
623 fn response(status: http::StatusCode, body: &str) -> http::Response<Bytes> {
624 http::Response::builder()
625 .status(status)
626 .body(Bytes::copy_from_slice(body.as_bytes()))
627 .expect("response must build")
628 }
629
630 fn iam_success_response(access_token: &str, expire_time: &str) -> http::Response<Bytes> {
631 let mut value: serde_json::Value = serde_json::from_slice(include_bytes!(
632 "../tests/fixtures/iam_generate_access_token_response.json"
633 ))
634 .expect("real IAM Credentials response fixture must parse");
635 value["accessToken"] = access_token.into();
636 value["expireTime"] = expire_time.into();
637 response(
638 http::StatusCode::OK,
639 &serde_json::to_string(&value).expect("IAM response fixture must serialize"),
640 )
641 }
642
643 fn source(expires_at: &str) -> Credential {
644 Credential::with_token(Token {
645 access_token: "source-secret-token".to_string(),
646 expires_at: Some(timestamp(expires_at)),
647 })
648 }
649
650 fn grant() -> ServiceAccountImpersonationGrant {
651 ServiceAccountImpersonationGrant::new(
652 "target@example-project.iam.gserviceaccount.com",
653 [
654 "https://www.googleapis.com/auth/cloud-platform",
655 "https://www.googleapis.com/auth/devstorage.read_only",
656 ],
657 )
658 .with_delegates([
659 "delegate@example-project.iam.gserviceaccount.com",
660 "123456789012345678901",
661 ])
662 }
663
664 #[tokio::test]
665 async fn constructs_canonical_request_and_preserves_authoritative_expiration() -> Result<()> {
666 let now = timestamp("2026-09-02T00:00:00Z");
667 let expires_at = timestamp("2026-09-02T01:00:00Z");
668 let http = MockHttpSend::new([iam_success_response(
669 "impersonated-secret-token",
670 "2026-09-02T01:00:00Z",
671 )]);
672 let granter = ServiceAccountImpersonationGranter::new(grant()).with_time(now);
673
674 let credential = granter
675 .grant_credential(
676 &Context::new().with_http_send(http.clone()),
677 &source("2026-09-02T02:00:00Z"),
678 Some(Duration::from_millis(3_600_500)),
679 )
680 .await?;
681
682 assert_eq!(
683 credential.signer_email.as_deref(),
684 Some("target@example-project.iam.gserviceaccount.com")
685 );
686 let token = credential.token.expect("token must exist");
687 assert_eq!(token.access_token, "impersonated-secret-token");
688 assert_eq!(token.expires_at, Some(expires_at));
689 assert!(credential.service_account.is_none());
690
691 let requests = http.requests.lock().expect("lock poisoned");
692 let request = requests.first().expect("request must be captured");
693 assert_eq!(request.method(), http::Method::POST);
694 assert_eq!(
695 request.uri(),
696 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target%40example-project.iam.gserviceaccount.com:generateAccessToken"
697 );
698 let authorization = request
699 .headers()
700 .get(AUTHORIZATION)
701 .expect("authorization must exist");
702 assert_eq!(authorization, "Bearer source-secret-token");
703 assert!(authorization.is_sensitive());
704 assert_eq!(
705 serde_json::from_slice::<serde_json::Value>(request.body())
706 .expect("request body must parse"),
707 json!({
708 "scope": [
709 "https://www.googleapis.com/auth/cloud-platform",
710 "https://www.googleapis.com/auth/devstorage.read_only"
711 ],
712 "delegates": [
713 "projects/-/serviceAccounts/delegate@example-project.iam.gserviceaccount.com",
714 "projects/-/serviceAccounts/123456789012345678901"
715 ],
716 "lifetime": "3600.5s"
717 })
718 );
719 Ok(())
720 }
721
722 #[test]
723 fn formats_and_validates_fractional_lifetimes() {
724 assert_eq!(format_lifetime(Duration::from_secs(3600)), "3600s");
725 assert_eq!(format_lifetime(Duration::from_millis(3_600_500)), "3600.5s");
726 assert_eq!(format_lifetime(Duration::new(10, 1)), "10.000000001s");
727
728 assert!(
729 ServiceAccountImpersonationGranter::validate_lifetime(Some(Duration::new(10, 1)))
730 .is_ok()
731 );
732 assert!(
733 ServiceAccountImpersonationGranter::validate_lifetime(Some(MAX_ACCESS_TOKEN_LIFETIME))
734 .is_ok()
735 );
736 assert!(
737 ServiceAccountImpersonationGranter::validate_lifetime(Some(
738 MAX_ACCESS_TOKEN_LIFETIME + Duration::from_nanos(1)
739 ))
740 .is_err()
741 );
742 }
743
744 #[tokio::test]
745 async fn rejects_invalid_input_before_iam_io() {
746 let now = timestamp("2026-09-02T00:00:00Z");
747 let http = MockHttpSend::new([]);
748 let context = Context::new().with_http_send(http.clone());
749
750 let invalid_cases = [
751 (
752 ServiceAccountImpersonationGranter::new(grant()).with_time(now),
753 Credential::with_service_account(ServiceAccount {
754 client_email: "source@example-project.iam.gserviceaccount.com".to_string(),
755 private_key: "private-key".to_string(),
756 }),
757 Some(Duration::from_secs(3600)),
758 ),
759 (
760 ServiceAccountImpersonationGranter::new(grant()).with_time(now),
761 Credential::with_token(Token {
762 access_token: "source-secret-token".to_string(),
763 expires_at: None,
764 }),
765 Some(Duration::from_secs(3600)),
766 ),
767 (
768 ServiceAccountImpersonationGranter::new(grant()).with_time(now),
769 Credential::with_token(Token {
770 access_token: "malformed source token".to_string(),
771 expires_at: Some(timestamp("2026-09-02T02:00:00Z")),
772 }),
773 Some(Duration::from_secs(3600)),
774 ),
775 (
776 ServiceAccountImpersonationGranter::new(ServiceAccountImpersonationGrant::new(
777 "not-a-service-account@example.com",
778 ["https://www.googleapis.com/auth/cloud-platform"],
779 ))
780 .with_time(now),
781 source("2026-09-02T02:00:00Z"),
782 Some(Duration::from_secs(3600)),
783 ),
784 (
785 ServiceAccountImpersonationGranter::new(ServiceAccountImpersonationGrant::new(
786 "target@example-project.iam.gserviceaccount.com",
787 Vec::<String>::new(),
788 ))
789 .with_time(now),
790 source("2026-09-02T02:00:00Z"),
791 Some(Duration::from_secs(3600)),
792 ),
793 (
794 ServiceAccountImpersonationGranter::new(
795 ServiceAccountImpersonationGrant::new(
796 "target@example-project.iam.gserviceaccount.com",
797 ["https://www.googleapis.com/auth/cloud-platform"],
798 )
799 .with_delegate("invalid/delegate"),
800 )
801 .with_time(now),
802 source("2026-09-02T02:00:00Z"),
803 Some(Duration::from_secs(3600)),
804 ),
805 (
806 ServiceAccountImpersonationGranter::new(grant()).with_time(now),
807 source("2026-09-02T02:00:00Z"),
808 Some(Duration::from_secs(10)),
809 ),
810 (
811 ServiceAccountImpersonationGranter::new(grant()).with_time(now),
812 source("2026-09-02T02:00:00Z"),
813 Some(Duration::from_secs(43_201)),
814 ),
815 ];
816
817 for (granter, credential, lifetime) in invalid_cases {
818 let error = granter
819 .grant_credential(&context, &credential, lifetime)
820 .await
821 .expect_err("input must be rejected");
822 assert!(matches!(
823 error.kind(),
824 ErrorKind::CredentialInvalid | ErrorKind::RequestInvalid
825 ));
826 }
827 assert_eq!(http.request_count(), 0);
828 }
829
830 #[tokio::test]
831 async fn validates_source_and_output_after_iam_io() {
832 let now = timestamp("2026-09-02T00:00:00Z");
833 let response_time = timestamp("2026-09-02T00:00:20Z");
834 let http = MockHttpSend::new([
835 iam_success_response("impersonated-token", "2026-09-02T01:00:00Z"),
836 iam_success_response("impersonated-token", "2026-09-02T00:00:25Z"),
837 ]);
838 let context = Context::new().with_http_send(http);
839
840 let source_expired = ServiceAccountImpersonationGranter::new(grant())
841 .with_time(now)
842 .with_time_after_request(response_time)
843 .grant_credential(
844 &context,
845 &source("2026-09-02T00:00:15Z"),
846 Some(Duration::from_secs(3600)),
847 )
848 .await
849 .expect_err("source expiration during I/O must fail");
850 assert_eq!(source_expired.kind(), ErrorKind::CredentialInvalid);
851
852 let output_too_short = ServiceAccountImpersonationGranter::new(grant())
853 .with_time(now)
854 .with_time_after_request(response_time)
855 .grant_credential(
856 &context,
857 &source("2026-09-02T02:00:00Z"),
858 Some(Duration::from_secs(3600)),
859 )
860 .await
861 .expect_err("short output expiration must fail");
862 assert_eq!(output_too_short.kind(), ErrorKind::CredentialInvalid);
863 }
864
865 #[tokio::test]
866 async fn redacts_debug_and_provider_errors() {
867 let grant = grant();
868 let grant_debug = format!("{grant:?}");
869 assert!(!grant_debug.contains("target@"));
870 assert!(!grant_debug.contains("delegate@"));
871 assert!(!grant_debug.contains("cloud-platform"));
872
873 let http = MockHttpSend::new([response(
874 http::StatusCode::FORBIDDEN,
875 r#"{"error":{"message":"source-secret-token delegate@example-project.iam.gserviceaccount.com"}}"#,
876 )]);
877 let error = ServiceAccountImpersonationGranter::new(grant)
878 .with_time(timestamp("2026-09-02T00:00:00Z"))
879 .grant_credential(
880 &Context::new().with_http_send(http),
881 &source("2026-09-02T02:00:00Z"),
882 None,
883 )
884 .await
885 .expect_err("permission denial must fail");
886 assert_eq!(error.kind(), ErrorKind::PermissionDenied);
887 let error_debug = format!("{error:?}");
888 assert!(!error_debug.contains("source-secret-token"));
889 assert!(!error_debug.contains("delegate@"));
890 assert!(error_debug.contains("http_status: 403"));
891 }
892
893 #[tokio::test]
894 async fn provider_composes_without_a_blanket_adapter() -> Result<()> {
895 let calls = Arc::new(AtomicUsize::new(0));
896 let provider = CountingProvider {
897 calls: calls.clone(),
898 credential: Some(source("2100-01-01T00:00:00Z")),
899 };
900 let http = MockHttpSend::new([
901 iam_success_response("first-token", "2100-01-01T00:00:00Z"),
902 iam_success_response("second-token", "2100-01-01T00:00:00Z"),
903 ]);
904 let context = Context::new().with_http_send(http.clone());
905 let granter = Granter::new(
906 context,
907 provider,
908 ServiceAccountImpersonationGranter::new(grant()),
909 );
910
911 let first = granter.grant(None).await?;
912 let second = granter.grant(None).await?;
913 assert_eq!(
914 first.token.expect("token must exist").access_token,
915 "first-token"
916 );
917 assert_eq!(
918 second.token.expect("token must exist").access_token,
919 "second-token"
920 );
921 assert_eq!(calls.load(Ordering::SeqCst), 1);
922 assert_eq!(http.request_count(), 2);
923
924 let wrapper_http = MockHttpSend::new([iam_success_response(
925 "provider-token",
926 "2100-01-01T00:00:00Z",
927 )]);
928 let wrapper = ServiceAccountImpersonationCredentialProvider::new(
929 CountingProvider {
930 calls: calls.clone(),
931 credential: Some(source("2100-01-01T00:00:00Z")),
932 },
933 grant(),
934 );
935 let credential = wrapper
936 .provide_credential(&Context::new().with_http_send(wrapper_http.clone()))
937 .await?
938 .expect("provider must return an impersonated credential");
939 assert_eq!(
940 credential.token.expect("token must exist").access_token,
941 "provider-token"
942 );
943 {
944 let requests = wrapper_http.requests.lock().expect("lock poisoned");
945 let body = serde_json::from_slice::<serde_json::Value>(
946 requests.first().expect("request must exist").body(),
947 )
948 .expect("request body must parse");
949 assert!(body.get("lifetime").is_none());
950 }
951
952 let empty_provider = ServiceAccountImpersonationCredentialProvider::new(
953 CountingProvider {
954 calls,
955 credential: None,
956 },
957 grant(),
958 );
959 assert!(
960 empty_provider
961 .provide_credential(&Context::new())
962 .await?
963 .is_none()
964 );
965 Ok(())
966 }
967
968 #[tokio::test]
969 async fn rejects_malformed_iam_response() {
970 let cases = [
971 r#"{"accessToken":"","expireTime":"2026-09-02T01:00:00Z"}"#,
972 r#"{"accessToken":"token","expireTime":"not-a-time"}"#,
973 r#"{"accessToken":"token"}"#,
974 ];
975
976 for body in cases {
977 let http = MockHttpSend::new([response(http::StatusCode::OK, body)]);
978 let error = ServiceAccountImpersonationGranter::new(grant())
979 .with_time(timestamp("2026-09-02T00:00:00Z"))
980 .grant_credential(
981 &Context::new().with_http_send(http),
982 &source("2026-09-02T02:00:00Z"),
983 None,
984 )
985 .await
986 .expect_err("malformed response must fail");
987 assert_eq!(error.kind(), ErrorKind::Unexpected);
988 }
989 }
990}