Skip to main content

reqsign_google/
service_account_impersonation.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::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/// A typed, explicitly bound IAM service-account impersonation grant.
39///
40/// The target service-account email, OAuth scopes, and delegate chain are
41/// stable authorization configuration. Construct another grant (or use
42/// [`ServiceAccountImpersonationGranter::with_grant`]) to change them. A
43/// requested token lifetime is supplied separately to
44/// [`reqsign_core::Granter::grant`].
45///
46/// Delegate values are service-account emails or numeric service-account IDs.
47/// Reqsign converts them into the canonical
48/// `projects/-/serviceAccounts/{identity}` resource names required by Google.
49#[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    /// Create a grant for a target service-account email and OAuth scopes.
65    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    /// Append a delegate service-account email or numeric service-account ID.
77    pub fn with_delegate(mut self, delegate: impl Into<String>) -> Self {
78        self.delegates.push(delegate.into());
79        self
80    }
81
82    /// Append a chain of delegate service-account emails or numeric IDs.
83    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/// Exchanges a token-only Google credential through IAM Credentials
136/// `generateAccessToken`.
137///
138/// The source must contain an OAuth access token with a known absolute
139/// expiration and no service-account private key. The source must remain valid
140/// long enough for the IAM request. The returned credential is token-only,
141/// carries Google's authoritative `expireTime`, preserves the target signer
142/// identity for query signing, and is directly consumable by Google request
143/// signers and Credential Access Boundary granters.
144///
145/// `None` as the requested lifetime omits the `lifetime` field and lets Google
146/// apply its one-hour default. Explicit lifetimes must leave enough time for a
147/// Google operation and not exceed 12 hours. Google requires an organization
148/// policy to allow lifetimes above one hour.
149///
150/// Every call performs a new IAM Credentials exchange. Granted outputs are not
151/// cached by this service granter.
152///
153/// # Example
154///
155/// ```no_run
156/// use std::time::Duration;
157///
158/// use reqsign_core::{Context, Granter, time::Timestamp};
159/// use reqsign_google::{
160///     ServiceAccountImpersonationGrant, ServiceAccountImpersonationGranter,
161///     TokenCredentialProvider,
162/// };
163///
164/// # async fn example() -> reqsign_core::Result<()> {
165/// let source = TokenCredentialProvider::new("source-oauth-token")
166///     .with_expires_at(Timestamp::now() + Duration::from_secs(3600));
167/// let grant = ServiceAccountImpersonationGrant::new(
168///     "target@example.iam.gserviceaccount.com",
169///     ["https://www.googleapis.com/auth/cloud-platform"],
170/// );
171/// // Supply a Context configured with an HttpSend implementation.
172/// let context = Context::new();
173/// let credential = Granter::new(
174///     context,
175///     source,
176///     ServiceAccountImpersonationGranter::new(grant),
177/// )
178/// .grant(Some(Duration::from_secs(3600)))
179/// .await?;
180/// # let _ = credential;
181/// # Ok(())
182/// # }
183/// ```
184#[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    /// Create a granter for a bound service-account impersonation grant.
202    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    /// Replace the bound target, scopes, and delegates.
213    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/// A Google-specific provider that loads a source credential and applies a
346/// fixed service-account impersonation flow.
347///
348/// This wrapper is useful when the impersonated result must participate in a
349/// [`reqsign_core::Signer`] or another provider-oriented composition. It does
350/// not cache the source or result; the consuming signer owns output caching.
351#[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    /// Create a provider from a source provider and bound impersonation grant.
367    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    /// Request a fixed lifetime for each impersonated credential.
379    pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
380        self.lifetime = Some(lifetime);
381        self
382    }
383
384    /// Replace the bound target, scopes, and delegates.
385    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}