Skip to main content

reqsign_google/credential_access_boundary/
client_side.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::VecDeque;
19use std::fmt::{self, Debug};
20use std::sync::{Arc, Mutex as SyncMutex, Weak};
21use std::time::Duration;
22
23use aes_gcm::aead::{Aead, Generate, KeyInit, array::Array};
24use aes_gcm::{Aes128Gcm, Aes256Gcm};
25use base64::Engine;
26use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
27use form_urlencoded::Serializer;
28use futures::lock::Mutex;
29use http::header::{ACCEPT, CONTENT_TYPE};
30use prost::Message;
31use reqsign_core::hash::hex_sha256;
32use reqsign_core::time::Timestamp;
33use reqsign_core::{Context, Error, GrantCredential, Result, SigningCredential};
34use serde::Deserialize;
35use zeroize::{Zeroize, Zeroizing};
36
37use super::CredentialAccessBoundaryGrant;
38use super::sts::{
39    ACCESS_TOKEN_TYPE, MAX_ACCESS_TOKEN_LIFETIME, STS_ENDPOINT, TOKEN_EXCHANGE_GRANT_TYPE,
40    checked_expiration, sts_error,
41};
42use crate::constants::TOKEN_OPERATION_HEADROOM;
43use crate::{Credential, Token};
44
45const ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE: &str =
46    "urn:ietf:params:oauth:token-type:access_boundary_intermediary_token";
47const TOKEN_EXCHANGE_HEADROOM: Duration = Duration::from_secs(10);
48const MAX_MINIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(12 * 60 * 60 - 1);
49const DEFAULT_MINIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 60);
50const INTERMEDIARY_CACHE_CAPACITY: usize = 64;
51const MAX_SESSION_KEY_ENCODED_BYTES: usize = 64 * 1024;
52const MAX_SESSION_KEYSET_KEYS: usize = 32;
53const AES_GCM_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.AesGcmKey";
54const TINK_KEY_STATUS_ENABLED: i32 = 1;
55const TINK_KEY_MATERIAL_SYMMETRIC: i32 = 1;
56const TINK_PREFIX: i32 = 1;
57const LEGACY_PREFIX: i32 = 2;
58const RAW_PREFIX: i32 = 3;
59const CRUNCHY_PREFIX: i32 = 4;
60const AES_GCM_KEY_VERSION: u32 = 0;
61const AES_GCM_NONCE_BYTES: usize = 12;
62
63#[derive(Deserialize)]
64struct StsTokenResponse {
65    access_token: String,
66    issued_token_type: String,
67    token_type: String,
68    #[serde(default)]
69    expires_in: Option<u64>,
70    #[serde(default)]
71    access_boundary_session_key: Option<String>,
72}
73
74#[derive(Clone, PartialEq, Eq)]
75struct IntermediaryCacheKey {
76    endpoint: &'static str,
77    source_authority: String,
78    source_expires_at: Timestamp,
79}
80
81struct IntermediaryCredentials {
82    access_token: Zeroizing<String>,
83    expires_at: Timestamp,
84    aead_key: TinkAesGcmKey,
85}
86
87impl Debug for IntermediaryCredentials {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.debug_struct("IntermediaryCredentials")
90            .field("access_token", &"REDACTED")
91            .field("expires_at", &self.expires_at)
92            .field("aead_key", &"REDACTED")
93            .finish()
94    }
95}
96
97impl IntermediaryCredentials {
98    fn covers(&self, now: Timestamp, minimum_lifetime: Duration) -> Result<bool> {
99        let required_until = checked_expiration(now, minimum_lifetime)?;
100        Ok(self.expires_at > required_until)
101    }
102}
103
104#[derive(Default)]
105struct IntermediaryCache {
106    entries: VecDeque<(IntermediaryCacheKey, Arc<IntermediaryCredentials>)>,
107}
108
109impl IntermediaryCache {
110    fn find(
111        &mut self,
112        key: &IntermediaryCacheKey,
113        now: Timestamp,
114        minimum_lifetime: Duration,
115    ) -> Result<Option<Arc<IntermediaryCredentials>>> {
116        self.entries
117            .retain(|(_, credentials)| credentials.expires_at > now);
118        let mut index = None;
119        for (candidate_index, (candidate, credentials)) in self.entries.iter().enumerate() {
120            if candidate == key && credentials.covers(now, minimum_lifetime)? {
121                index = Some(candidate_index);
122                break;
123            }
124        }
125        let Some(index) = index else {
126            return Ok(None);
127        };
128
129        let entry = self.entries.remove(index).ok_or_else(|| {
130            Error::unexpected("client-side CAB intermediary cache is inconsistent")
131        })?;
132        let credentials = entry.1.clone();
133        self.entries.push_back(entry);
134        Ok(Some(credentials))
135    }
136
137    fn insert(
138        &mut self,
139        key: IntermediaryCacheKey,
140        credentials: Arc<IntermediaryCredentials>,
141        now: Timestamp,
142    ) {
143        self.entries
144            .retain(|(candidate, cached)| candidate != &key && cached.expires_at > now);
145        while self.entries.len() >= INTERMEDIARY_CACHE_CAPACITY {
146            self.entries.pop_front();
147        }
148        self.entries.push_back((key, credentials));
149    }
150
151    #[cfg(test)]
152    fn len(&self) -> usize {
153        self.entries.len()
154    }
155}
156
157#[derive(Default)]
158struct IntermediaryState {
159    cache: Mutex<IntermediaryCache>,
160    refresh_locks: RefreshLockRegistry,
161}
162
163impl IntermediaryState {
164    fn refresh_lock(&self, key: &IntermediaryCacheKey) -> RefreshLockLease {
165        let mut refresh_locks = self
166            .refresh_locks
167            .lock()
168            .unwrap_or_else(std::sync::PoisonError::into_inner);
169        if let Some(lock) = refresh_locks
170            .iter()
171            .find_map(|(candidate, lock)| (candidate == key).then(|| lock.upgrade()).flatten())
172        {
173            return RefreshLockLease {
174                key: key.clone(),
175                lock,
176                registry: self.refresh_locks.clone(),
177            };
178        }
179
180        let lock = Arc::new(Mutex::new(()));
181        refresh_locks.push((key.clone(), Arc::downgrade(&lock)));
182        RefreshLockLease {
183            key: key.clone(),
184            lock,
185            registry: self.refresh_locks.clone(),
186        }
187    }
188
189    #[cfg(test)]
190    fn refresh_lock_len(&self) -> usize {
191        self.refresh_locks
192            .lock()
193            .unwrap_or_else(std::sync::PoisonError::into_inner)
194            .len()
195    }
196}
197
198struct RefreshLockLease {
199    key: IntermediaryCacheKey,
200    lock: Arc<Mutex<()>>,
201    registry: RefreshLockRegistry,
202}
203
204type RefreshLockRegistry = Arc<SyncMutex<Vec<(IntermediaryCacheKey, Weak<Mutex<()>>)>>>;
205
206impl Drop for RefreshLockLease {
207    fn drop(&mut self) {
208        let mut registry = self
209            .registry
210            .lock()
211            .unwrap_or_else(std::sync::PoisonError::into_inner);
212        if Arc::strong_count(&self.lock) != 1 {
213            return;
214        }
215        registry.retain(|(candidate, weak)| {
216            candidate != &self.key
217                || weak
218                    .upgrade()
219                    .is_none_or(|lock| !Arc::ptr_eq(&lock, &self.lock))
220        });
221    }
222}
223
224/// Exchanges source credentials for intermediary material and generates Google
225/// Credential Access Boundary tokens locally.
226///
227/// The granter exchanges the source OAuth access token for an access-boundary
228/// intermediary token and session key when the cached intermediary no longer
229/// covers the requested lifetime, then creates every downscoped credential
230/// locally. It does not spawn background work; the first caller that needs a
231/// refresh performs the exchange. Refreshes for the same source authority,
232/// expiration, and endpoint are serialized, while unrelated partitions can
233/// proceed independently. After a failed refresh, the next waiting caller
234/// retries instead of receiving cached failure state. Clones and values produced
235/// with [`ClientSideCredentialAccessBoundaryGranter::with_grant`] share a bounded
236/// intermediary cache partitioned by the source token authority, its declared
237/// expiration, and the Google STS endpoint. Cancellation never installs a
238/// partially fetched intermediary, and refresh-lock registry entries live only
239/// while callers for that partition are active.
240///
241/// Each call performs fresh authenticated encryption and returns a distinct,
242/// token-only [`Credential`]. The output expiration exactly matches the
243/// intermediary expiration. The optional lifetime passed to
244/// [`reqsign_core::Granter::grant`] is treated as the minimum remaining output
245/// lifetime; it does not shorten the token. `None` uses the 30-minute default,
246/// which can be changed with
247/// [`ClientSideCredentialAccessBoundaryGranter::with_minimum_token_lifetime`].
248///
249/// The source must be a token-only Google OAuth access token for a service
250/// account, with a known absolute expiration and the Cloud Platform scope.
251/// Client-issued CAB tokens do not support user principals. STS rejects tokens
252/// that already carry security attributes; the opaque source token does not
253/// expose enough information to detect its principal, scope, or existing
254/// attributes locally. An intermediary response without `expires_in`, which STS
255/// uses for user access-token sources, is rejected.
256///
257/// Google currently returns a serialized Tink AEAD keyset as the session key.
258/// This implementation accepts enabled primary AES-GCM keys and fails closed for
259/// other key types.
260///
261/// # Example
262///
263/// ```no_run
264/// use std::time::Duration;
265///
266/// use reqsign_core::{Context, Granter, time::Timestamp};
267/// use reqsign_google::{
268///     ClientSideCredentialAccessBoundaryGranter, CredentialAccessBoundaryGrant,
269///     CredentialAccessBoundaryPermissions, TokenCredentialProvider,
270/// };
271///
272/// # async fn example() -> reqsign_core::Result<()> {
273/// let source = TokenCredentialProvider::new("source-oauth-token")
274///     .with_expires_at(Timestamp::now() + Duration::from_secs(3600));
275/// let grant = CredentialAccessBoundaryGrant::for_object_prefix(
276///     "example-bucket",
277///     "customer-a/",
278///     CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
279/// );
280/// // Supply a Context configured with an HttpSend implementation.
281/// let context = Context::new();
282/// let credential = Granter::new(
283///     context,
284///     source,
285///     ClientSideCredentialAccessBoundaryGranter::new(grant),
286/// )
287/// .grant(None)
288/// .await?;
289/// # let _ = credential;
290/// # Ok(())
291/// # }
292/// ```
293#[derive(Clone)]
294pub struct ClientSideCredentialAccessBoundaryGranter {
295    grant: CredentialAccessBoundaryGrant,
296    minimum_token_lifetime: Duration,
297    intermediary_state: Arc<IntermediaryState>,
298    #[cfg(test)]
299    now: Option<Timestamp>,
300    #[cfg(test)]
301    time_after_request: Option<Timestamp>,
302    #[cfg(test)]
303    time_after_generation: Option<Timestamp>,
304    #[cfg(test)]
305    nonces: Option<Arc<std::sync::Mutex<VecDeque<[u8; AES_GCM_NONCE_BYTES]>>>>,
306}
307
308impl Debug for ClientSideCredentialAccessBoundaryGranter {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        f.debug_struct("ClientSideCredentialAccessBoundaryGranter")
311            .finish_non_exhaustive()
312    }
313}
314
315impl ClientSideCredentialAccessBoundaryGranter {
316    /// Create a granter for a bound Credential Access Boundary.
317    pub fn new(grant: CredentialAccessBoundaryGrant) -> Self {
318        Self {
319            grant,
320            minimum_token_lifetime: DEFAULT_MINIMUM_TOKEN_LIFETIME,
321            intermediary_state: Arc::new(IntermediaryState::default()),
322            #[cfg(test)]
323            now: None,
324            #[cfg(test)]
325            time_after_request: None,
326            #[cfg(test)]
327            time_after_generation: None,
328            #[cfg(test)]
329            nonces: None,
330        }
331    }
332
333    /// Replace the bound grant while retaining the shared intermediary cache.
334    pub fn with_grant(mut self, grant: CredentialAccessBoundaryGrant) -> Self {
335        self.grant = grant;
336        self
337    }
338
339    /// Set the minimum remaining lifetime for outputs granted without an
340    /// explicit per-call lifetime.
341    ///
342    /// The value is rounded up to a whole second and must then be greater than
343    /// zero and strictly less than twelve hours. The largest accepted value is
344    /// 11 hours, 59 minutes, and 59 seconds. Validation occurs before STS I/O.
345    pub fn with_minimum_token_lifetime(mut self, lifetime: Duration) -> Self {
346        self.minimum_token_lifetime = lifetime;
347        self
348    }
349
350    fn now(&self) -> Timestamp {
351        #[cfg(test)]
352        if let Some(now) = self.now {
353            return now;
354        }
355        Timestamp::now()
356    }
357
358    fn time_after_request(&self) -> Timestamp {
359        #[cfg(test)]
360        if let Some(now) = self.time_after_request {
361            return now;
362        }
363        #[cfg(test)]
364        if let Some(now) = self.now {
365            return now;
366        }
367        Timestamp::now()
368    }
369
370    fn time_after_generation(&self) -> Timestamp {
371        #[cfg(test)]
372        if let Some(now) = self.time_after_generation {
373            return now;
374        }
375        #[cfg(test)]
376        if let Some(now) = self.time_after_request {
377            return now;
378        }
379        #[cfg(test)]
380        if let Some(now) = self.now {
381            return now;
382        }
383        Timestamp::now()
384    }
385
386    #[cfg(test)]
387    fn with_time(mut self, now: Timestamp) -> Self {
388        self.now = Some(now);
389        self.time_after_request = Some(now);
390        self.time_after_generation = Some(now);
391        self
392    }
393
394    #[cfg(test)]
395    fn with_time_after_request(mut self, now: Timestamp) -> Self {
396        self.time_after_request = Some(now);
397        self.time_after_generation = Some(now);
398        self
399    }
400
401    #[cfg(test)]
402    fn with_time_after_generation(mut self, now: Timestamp) -> Self {
403        self.time_after_generation = Some(now);
404        self
405    }
406
407    #[cfg(test)]
408    fn with_nonces(mut self, nonces: impl IntoIterator<Item = [u8; AES_GCM_NONCE_BYTES]>) -> Self {
409        self.nonces = Some(Arc::new(std::sync::Mutex::new(
410            nonces.into_iter().collect(),
411        )));
412        self
413    }
414
415    fn effective_minimum_lifetime(&self, expires_in: Option<Duration>) -> Result<Duration> {
416        let requested = expires_in.unwrap_or(self.minimum_token_lifetime);
417        if requested.is_zero() {
418            return Err(Error::request_invalid(
419                "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
420            ));
421        }
422        let whole_seconds = requested
423            .as_secs()
424            .checked_add(u64::from(requested.subsec_nanos() != 0))
425            .ok_or_else(|| {
426                Error::request_invalid(
427                    "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
428                )
429        })?;
430        let requested = Duration::from_secs(whole_seconds).max(TOKEN_OPERATION_HEADROOM);
431        if requested > MAX_MINIMUM_TOKEN_LIFETIME {
432            return Err(Error::request_invalid(
433                "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
434            ));
435        }
436        Ok(requested)
437    }
438
439    fn source_token<'a>(
440        &self,
441        credential: &'a Credential,
442        required_until: Timestamp,
443    ) -> Result<&'a Token> {
444        if credential.service_account.is_some() {
445            return Err(Error::credential_invalid(
446                "client-side credential access boundary requires a token-only source credential",
447            ));
448        }
449        let token = credential.token.as_ref().ok_or_else(|| {
450            Error::credential_invalid(
451                "client-side credential access boundary requires an OAuth access token",
452            )
453        })?;
454        if token.access_token.is_empty() {
455            return Err(Error::credential_invalid(
456                "client-side credential access boundary source access token is empty",
457            ));
458        }
459        if token.expires_at.is_none() {
460            return Err(Error::credential_invalid(
461                "client-side credential access boundary source token expiration is required",
462            ));
463        }
464        if !token.is_valid_at(required_until) {
465            return Err(Error::credential_invalid(
466                "source OAuth access token expires before the client-side CAB intermediary exchange can complete",
467            ));
468        }
469        Ok(token)
470    }
471
472    fn build_intermediary_request(
473        &self,
474        source_token: &str,
475    ) -> Result<http::Request<bytes::Bytes>> {
476        let body = Serializer::new(String::new())
477            .append_pair("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
478            .append_pair(
479                "requested_token_type",
480                ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
481            )
482            .append_pair("subject_token_type", ACCESS_TOKEN_TYPE)
483            .append_pair("subject_token", source_token)
484            .finish();
485
486        http::Request::builder()
487            .method(http::Method::POST)
488            .uri(STS_ENDPOINT)
489            .header(ACCEPT, "application/json")
490            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
491            .body(body.into_bytes().into())
492            .map_err(|err| {
493                Error::unexpected("failed to build client-side CAB intermediary request")
494                    .with_source(err)
495            })
496    }
497
498    fn parse_intermediary_response(
499        &self,
500        response: http::Response<bytes::Bytes>,
501        source: &Token,
502        response_time: Timestamp,
503        minimum_lifetime: Duration,
504    ) -> Result<IntermediaryCredentials> {
505        if response.status() != http::StatusCode::OK {
506            return Err(sts_error(response.status(), response.body()));
507        }
508
509        let mut token_response: StsTokenResponse = serde_json::from_slice(response.body())
510            .map_err(|_| {
511                Error::unexpected("failed to parse client-side CAB intermediary STS response")
512            })?;
513        if token_response.access_token.is_empty()
514            || token_response.issued_token_type != ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
515            || token_response.token_type != "Bearer"
516        {
517            return Err(Error::unexpected(
518                "client-side CAB intermediary STS response is malformed",
519            ));
520        }
521        let session_key = Zeroizing::new(
522            token_response
523                .access_boundary_session_key
524                .take()
525                .filter(|value| !value.is_empty())
526                .ok_or_else(|| {
527                    Error::unexpected("client-side CAB intermediary STS response is malformed")
528                })?,
529        );
530
531        let source_expiration = source.expires_at.ok_or_else(|| {
532            Error::credential_invalid(
533                "client-side credential access boundary source token expiration is required",
534            )
535        })?;
536        if source_expiration <= response_time {
537            return Err(Error::credential_invalid(
538                "source OAuth access token expired during the client-side CAB intermediary exchange",
539            ));
540        }
541
542        let expires_in = token_response.expires_in.ok_or_else(|| {
543            Error::credential_invalid(
544                "client-side credential access boundary requires a service-account source with an explicit STS expiration",
545            )
546        })?;
547        let expires_in = Duration::from_secs(expires_in);
548        if expires_in.is_zero() || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
549            return Err(Error::unexpected(
550                "client-side CAB intermediary STS expiration is invalid",
551            ));
552        }
553        let expires_at = checked_expiration(response_time, expires_in)?.min(source_expiration);
554        let required_until = checked_expiration(response_time, minimum_lifetime)?;
555        if expires_at <= required_until {
556            return Err(Error::credential_invalid(
557                "client-side CAB intermediary token is not valid for the minimum output lifetime",
558            ));
559        }
560
561        let aead_key = TinkAesGcmKey::parse(&session_key)?;
562        Ok(IntermediaryCredentials {
563            access_token: Zeroizing::new(token_response.access_token),
564            expires_at,
565            aead_key,
566        })
567    }
568
569    fn cache_key(&self, source: &Token) -> Result<IntermediaryCacheKey> {
570        let source_expires_at = source.expires_at.ok_or_else(|| {
571            Error::credential_invalid(
572                "client-side credential access boundary source token expiration is required",
573            )
574        })?;
575        Ok(IntermediaryCacheKey {
576            endpoint: STS_ENDPOINT,
577            source_authority: hex_sha256(source.access_token.as_bytes()),
578            source_expires_at,
579        })
580    }
581
582    async fn intermediary_credentials(
583        &self,
584        ctx: &Context,
585        source: &Token,
586        minimum_lifetime: Duration,
587    ) -> Result<Arc<IntermediaryCredentials>> {
588        let cache_key = self.cache_key(source)?;
589        let now = self.now();
590        if let Some(credentials) =
591            self.intermediary_state
592                .cache
593                .lock()
594                .await
595                .find(&cache_key, now, minimum_lifetime)?
596        {
597            return Ok(credentials);
598        }
599
600        let refresh_lock = self.intermediary_state.refresh_lock(&cache_key);
601        let _refresh_guard = refresh_lock.lock.lock().await;
602        let now = self.now();
603        if let Some(credentials) =
604            self.intermediary_state
605                .cache
606                .lock()
607                .await
608                .find(&cache_key, now, minimum_lifetime)?
609        {
610            return Ok(credentials);
611        }
612
613        let required_until = checked_expiration(
614            now,
615            minimum_lifetime.saturating_add(TOKEN_EXCHANGE_HEADROOM),
616        )?;
617        if !source.is_valid_at(required_until) {
618            return Err(Error::credential_invalid(
619                "source OAuth access token expires before the client-side CAB intermediary exchange can complete",
620            ));
621        }
622
623        let request = self.build_intermediary_request(&source.access_token)?;
624        let response = ctx.http_send(request).await.map_err(|err| {
625            Error::new(
626                err.kind(),
627                "client-side CAB intermediary STS request failed",
628            )
629            .set_retryable(err.is_retryable())
630        })?;
631        let response_time = self.time_after_request();
632        let credentials = Arc::new(self.parse_intermediary_response(
633            response,
634            source,
635            response_time,
636            minimum_lifetime,
637        )?);
638        self.intermediary_state.cache.lock().await.insert(
639            cache_key,
640            credentials.clone(),
641            response_time,
642        );
643        Ok(credentials)
644    }
645
646    fn next_nonce(&self) -> Result<[u8; AES_GCM_NONCE_BYTES]> {
647        #[cfg(test)]
648        if let Some(nonces) = &self.nonces {
649            return nonces
650                .lock()
651                .expect("lock poisoned")
652                .pop_front()
653                .ok_or_else(|| Error::unexpected("test CAB nonce queue is empty"));
654        }
655
656        <[u8; AES_GCM_NONCE_BYTES]>::try_generate()
657            .map_err(|_| Error::unexpected("failed to generate client-side CAB encryption nonce"))
658    }
659
660    #[cfg(test)]
661    async fn cache_len(&self) -> usize {
662        self.intermediary_state.cache.lock().await.len()
663    }
664
665    #[cfg(test)]
666    fn refresh_lock_len(&self) -> usize {
667        self.intermediary_state.refresh_lock_len()
668    }
669}
670
671impl GrantCredential for ClientSideCredentialAccessBoundaryGranter {
672    type Credential = Credential;
673
674    fn required_valid_until(
675        &self,
676        _credential: &Self::Credential,
677        expires_in: Option<Duration>,
678    ) -> Timestamp {
679        let minimum_lifetime = self
680            .effective_minimum_lifetime(expires_in)
681            .unwrap_or(TOKEN_OPERATION_HEADROOM);
682        self.now() + minimum_lifetime + TOKEN_EXCHANGE_HEADROOM
683    }
684
685    async fn grant_credential(
686        &self,
687        ctx: &Context,
688        credential: &Self::Credential,
689        expires_in: Option<Duration>,
690    ) -> Result<Self::Credential> {
691        let minimum_lifetime = self.effective_minimum_lifetime(expires_in)?;
692        let restrictions = serialize_restrictions(&self.grant)?;
693        let required_until = checked_expiration(
694            self.now(),
695            minimum_lifetime.saturating_add(TOKEN_EXCHANGE_HEADROOM),
696        )?;
697        let source = self.source_token(credential, required_until)?;
698        let intermediary = self
699            .intermediary_credentials(ctx, source, minimum_lifetime)
700            .await?;
701
702        let nonce = self.next_nonce()?;
703        let encrypted = intermediary.aead_key.encrypt(&restrictions, &nonce)?;
704        let access_token = format!(
705            "{}.{}",
706            intermediary.access_token.as_str(),
707            URL_SAFE_NO_PAD.encode(encrypted)
708        );
709        let output = Credential::with_token(Token {
710            access_token,
711            expires_at: Some(intermediary.expires_at),
712        });
713
714        let completed_at = self.time_after_generation();
715        let required_until = checked_expiration(completed_at, minimum_lifetime)?;
716        if !output.is_valid_at(required_until) {
717            return Err(Error::credential_invalid(
718                "client-issued CAB token is not valid for the minimum output lifetime after generation",
719            ));
720        }
721        Ok(output)
722    }
723}
724
725struct TinkAesGcmKey {
726    output_prefix: Vec<u8>,
727    key_value: Zeroizing<Vec<u8>>,
728}
729
730impl Debug for TinkAesGcmKey {
731    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732        f.debug_struct("TinkAesGcmKey")
733            .field("output_prefix", &"REDACTED")
734            .field("key_value", &"REDACTED")
735            .finish()
736    }
737}
738
739impl TinkAesGcmKey {
740    fn parse(session_key: &str) -> Result<Self> {
741        if session_key.len() > MAX_SESSION_KEY_ENCODED_BYTES {
742            return Err(Error::unexpected(
743                "client-side CAB session key is malformed",
744            ));
745        }
746        let decoded = STANDARD
747            .decode(session_key)
748            .map(Zeroizing::new)
749            .map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
750        let mut keyset = TinkKeyset::decode(decoded.as_slice())
751            .map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
752        if keyset.keys.is_empty() || keyset.keys.len() > MAX_SESSION_KEYSET_KEYS {
753            zeroize_keyset(&mut keyset);
754            return Err(Error::unexpected(
755                "client-side CAB session key is malformed",
756            ));
757        }
758
759        let primary_indexes = keyset
760            .keys
761            .iter()
762            .enumerate()
763            .filter_map(|(index, key)| (key.key_id == keyset.primary_key_id).then_some(index))
764            .collect::<Vec<_>>();
765        if primary_indexes.len() != 1 {
766            zeroize_keyset(&mut keyset);
767            return Err(Error::unexpected(
768                "client-side CAB session key is malformed",
769            ));
770        }
771        let mut primary = keyset.keys.swap_remove(primary_indexes[0]);
772        zeroize_keyset(&mut keyset);
773        if primary.status != TINK_KEY_STATUS_ENABLED {
774            zeroize_key(&mut primary);
775            return Err(Error::unexpected(
776                "client-side CAB session key is malformed",
777            ));
778        }
779
780        let output_prefix = match primary.output_prefix_type {
781            TINK_PREFIX => {
782                let mut prefix = Vec::with_capacity(5);
783                prefix.push(1);
784                prefix.extend_from_slice(&primary.key_id.to_be_bytes());
785                prefix
786            }
787            LEGACY_PREFIX | CRUNCHY_PREFIX => {
788                let mut prefix = Vec::with_capacity(5);
789                prefix.push(0);
790                prefix.extend_from_slice(&primary.key_id.to_be_bytes());
791                prefix
792            }
793            RAW_PREFIX => Vec::new(),
794            _ => {
795                zeroize_key(&mut primary);
796                return Err(Error::unexpected(
797                    "client-side CAB session key is malformed",
798                ));
799            }
800        };
801
802        let mut key_data = primary
803            .key_data
804            .take()
805            .ok_or_else(|| Error::unexpected("client-side CAB session key is malformed"))?;
806        if key_data.type_url != AES_GCM_KEY_TYPE_URL
807            || key_data.key_material_type != TINK_KEY_MATERIAL_SYMMETRIC
808        {
809            key_data.value.zeroize();
810            return Err(Error::unexpected(
811                "client-side CAB session key uses an unsupported AEAD key",
812            ));
813        }
814        let aes_key = AesGcmKeyProto::decode(key_data.value.as_slice());
815        key_data.value.zeroize();
816        let mut aes_key =
817            aes_key.map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
818        if aes_key.version != AES_GCM_KEY_VERSION || !matches!(aes_key.key_value.len(), 16 | 32) {
819            aes_key.key_value.zeroize();
820            return Err(Error::unexpected(
821                "client-side CAB session key uses an unsupported AEAD key",
822            ));
823        }
824
825        Ok(Self {
826            output_prefix,
827            key_value: Zeroizing::new(aes_key.key_value),
828        })
829    }
830
831    fn encrypt(&self, restrictions: &[u8], nonce: &[u8; AES_GCM_NONCE_BYTES]) -> Result<Vec<u8>> {
832        let ciphertext = match self.key_value.len() {
833            16 => Aes128Gcm::new_from_slice(&self.key_value)
834                .map_err(|_| Error::unexpected("failed to initialize client-side CAB encryption"))?
835                .encrypt(&Array(*nonce), restrictions),
836            32 => Aes256Gcm::new_from_slice(&self.key_value)
837                .map_err(|_| Error::unexpected("failed to initialize client-side CAB encryption"))?
838                .encrypt(&Array(*nonce), restrictions),
839            _ => {
840                return Err(Error::unexpected(
841                    "client-side CAB session key uses an unsupported AEAD key",
842                ));
843            }
844        }
845        .map_err(|_| Error::unexpected("failed to encrypt client-side CAB restrictions"))?;
846
847        let mut encrypted =
848            Vec::with_capacity(self.output_prefix.len() + nonce.len() + ciphertext.len());
849        encrypted.extend_from_slice(&self.output_prefix);
850        encrypted.extend_from_slice(nonce);
851        encrypted.extend_from_slice(&ciphertext);
852        Ok(encrypted)
853    }
854}
855
856fn zeroize_keyset(keyset: &mut TinkKeyset) {
857    for key in &mut keyset.keys {
858        zeroize_key(key);
859    }
860}
861
862fn zeroize_key(key: &mut TinkKeysetKey) {
863    if let Some(key_data) = &mut key.key_data {
864        key_data.value.zeroize();
865    }
866}
867
868fn serialize_restrictions(grant: &CredentialAccessBoundaryGrant) -> Result<Vec<u8>> {
869    grant.validate()?;
870    let rules = grant
871        .rules
872        .iter()
873        .map(|rule| {
874            let available_resource = format!(
875                "//storage.googleapis.com/projects/_/buckets/{}",
876                rule.bucket
877            );
878            let available_permissions = rule
879                .permissions
880                .roles()?
881                .into_iter()
882                .map(str::to_owned)
883                .collect();
884            let compiled_availability_condition = rule
885                .object_prefix
886                .as_deref()
887                .map(|prefix| prefix_condition_expr(&rule.bucket, prefix));
888            Ok(ClientSideAccessBoundaryRule {
889                available_resource,
890                available_permissions,
891                compiled_availability_condition,
892            })
893        })
894        .collect::<Result<Vec<_>>>()?;
895    Ok(ClientSideAccessBoundary {
896        access_boundary_rules: rules,
897    }
898    .encode_to_vec())
899}
900
901fn prefix_condition_expr(bucket: &str, prefix: &str) -> CelExpr {
902    let object_resource_prefix = format!("projects/_/buckets/{bucket}/objects/{prefix}");
903    let resource_starts_with = call(
904        2,
905        Some(select(3, ident(4, "resource"), "name")),
906        "startsWith",
907        vec![string_constant(5, object_resource_prefix)],
908    );
909    let list_prefix = call(
910        6,
911        Some(call(
912            7,
913            Some(ident(8, "api")),
914            "getAttribute",
915            vec![
916                string_constant(9, "storage.googleapis.com/objectListPrefix"),
917                string_constant(10, ""),
918            ],
919        )),
920        "startsWith",
921        vec![string_constant(11, prefix)],
922    );
923    call(1, None, "_||_", vec![resource_starts_with, list_prefix])
924}
925
926fn ident(id: i64, name: impl Into<String>) -> CelExpr {
927    CelExpr {
928        id,
929        ident_expr: Some(CelIdent { name: name.into() }),
930        ..Default::default()
931    }
932}
933
934fn select(id: i64, operand: CelExpr, field: impl Into<String>) -> CelExpr {
935    CelExpr {
936        id,
937        select_expr: Some(CelSelect {
938            operand: Some(Box::new(operand)),
939            field: field.into(),
940            test_only: false,
941        }),
942        ..Default::default()
943    }
944}
945
946fn call(
947    id: i64,
948    target: Option<CelExpr>,
949    function: impl Into<String>,
950    args: Vec<CelExpr>,
951) -> CelExpr {
952    CelExpr {
953        id,
954        call_expr: Some(CelCall {
955            target: target.map(Box::new),
956            function: function.into(),
957            args,
958        }),
959        ..Default::default()
960    }
961}
962
963fn string_constant(id: i64, value: impl Into<String>) -> CelExpr {
964    CelExpr {
965        id,
966        const_expr: Some(CelConstant {
967            string_value: Some(value.into()),
968        }),
969        ..Default::default()
970    }
971}
972
973#[derive(Clone, PartialEq, Message)]
974struct ClientSideAccessBoundary {
975    #[prost(message, repeated, tag = "1")]
976    access_boundary_rules: Vec<ClientSideAccessBoundaryRule>,
977}
978
979#[derive(Clone, PartialEq, Message)]
980struct ClientSideAccessBoundaryRule {
981    #[prost(string, tag = "1")]
982    available_resource: String,
983    #[prost(string, repeated, tag = "2")]
984    available_permissions: Vec<String>,
985    #[prost(message, optional, tag = "4")]
986    compiled_availability_condition: Option<CelExpr>,
987}
988
989#[derive(Clone, PartialEq, Message)]
990struct CelExpr {
991    #[prost(int64, tag = "2")]
992    id: i64,
993    #[prost(message, optional, tag = "3")]
994    const_expr: Option<CelConstant>,
995    #[prost(message, optional, tag = "4")]
996    ident_expr: Option<CelIdent>,
997    #[prost(message, optional, tag = "5")]
998    select_expr: Option<CelSelect>,
999    #[prost(message, optional, tag = "6")]
1000    call_expr: Option<CelCall>,
1001}
1002
1003#[derive(Clone, PartialEq, Message)]
1004struct CelIdent {
1005    #[prost(string, tag = "1")]
1006    name: String,
1007}
1008
1009#[derive(Clone, PartialEq, Message)]
1010struct CelSelect {
1011    #[prost(message, optional, boxed, tag = "1")]
1012    operand: Option<Box<CelExpr>>,
1013    #[prost(string, tag = "2")]
1014    field: String,
1015    #[prost(bool, tag = "3")]
1016    test_only: bool,
1017}
1018
1019#[derive(Clone, PartialEq, Message)]
1020struct CelCall {
1021    #[prost(message, optional, boxed, tag = "1")]
1022    target: Option<Box<CelExpr>>,
1023    #[prost(string, tag = "2")]
1024    function: String,
1025    #[prost(message, repeated, tag = "3")]
1026    args: Vec<CelExpr>,
1027}
1028
1029#[derive(Clone, PartialEq, Message)]
1030struct CelConstant {
1031    #[prost(string, optional, tag = "6")]
1032    string_value: Option<String>,
1033}
1034
1035#[derive(Clone, PartialEq, Message)]
1036struct TinkKeyset {
1037    #[prost(uint32, tag = "1")]
1038    primary_key_id: u32,
1039    #[prost(message, repeated, tag = "2")]
1040    keys: Vec<TinkKeysetKey>,
1041}
1042
1043#[derive(Clone, PartialEq, Message)]
1044struct TinkKeysetKey {
1045    #[prost(message, optional, tag = "1")]
1046    key_data: Option<TinkKeyData>,
1047    #[prost(int32, tag = "2")]
1048    status: i32,
1049    #[prost(uint32, tag = "3")]
1050    key_id: u32,
1051    #[prost(int32, tag = "4")]
1052    output_prefix_type: i32,
1053}
1054
1055#[derive(Clone, PartialEq, Message)]
1056struct TinkKeyData {
1057    #[prost(string, tag = "1")]
1058    type_url: String,
1059    #[prost(bytes = "vec", tag = "2")]
1060    value: Vec<u8>,
1061    #[prost(int32, tag = "3")]
1062    key_material_type: i32,
1063}
1064
1065#[derive(Clone, PartialEq, Message)]
1066struct AesGcmKeyProto {
1067    #[prost(uint32, tag = "1")]
1068    version: u32,
1069    #[prost(bytes = "vec", tag = "3")]
1070    key_value: Vec<u8>,
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use std::collections::{BTreeMap, HashSet, VecDeque};
1076    use std::fmt::Formatter;
1077    use std::sync::atomic::{AtomicUsize, Ordering};
1078    use std::sync::{Arc, Mutex as StdMutex};
1079
1080    use bytes::Bytes;
1081    use http::header::{AUTHORIZATION, HeaderMap};
1082    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer, time::Timestamp};
1083    use tokio::sync::Semaphore;
1084
1085    use super::*;
1086    use crate::{CredentialAccessBoundaryPermissions, RequestSigner, ServiceAccount};
1087
1088    // This is the access-boundary session key returned by Google's
1089    // MockStsTransport in google-auth-library-java. It is a serialized Tink
1090    // AES-256-GCM keyset and contains no production credential material.
1091    const GOOGLE_AUTH_LIBRARY_SESSION_KEY: &str = concat!(
1092        "CPaEhYsKEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQW",
1093        "VzR2NtS2V5EiIaIMx8syvGIGGu5yvrdq/I0Q9ZWIR1oqJXFnDFxHuwX4SEGAEQARj2hIWLCiAB"
1094    );
1095
1096    #[derive(Clone)]
1097    struct CapturedRequest {
1098        method: http::Method,
1099        uri: http::Uri,
1100        headers: HeaderMap,
1101        body: Vec<u8>,
1102    }
1103
1104    impl Debug for CapturedRequest {
1105        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1106            f.debug_struct("CapturedRequest")
1107                .field("method", &self.method)
1108                .field("uri", &self.uri)
1109                .field("headers", &"REDACTED")
1110                .field("body", &"REDACTED")
1111                .finish()
1112        }
1113    }
1114
1115    #[derive(Clone)]
1116    struct RequestGate {
1117        started: Arc<Semaphore>,
1118        release: Arc<Semaphore>,
1119    }
1120
1121    impl RequestGate {
1122        async fn wait_started(&self) {
1123            self.started
1124                .acquire()
1125                .await
1126                .expect("started semaphore must remain open")
1127                .forget();
1128        }
1129
1130        fn release_one(&self) {
1131            self.release.add_permits(1);
1132        }
1133    }
1134
1135    #[derive(Clone)]
1136    struct MockHttpSend {
1137        calls: Arc<AtomicUsize>,
1138        requests: Arc<StdMutex<Vec<CapturedRequest>>>,
1139        responses: Arc<StdMutex<VecDeque<http::Response<Bytes>>>>,
1140        gate: Option<RequestGate>,
1141    }
1142
1143    impl Debug for MockHttpSend {
1144        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1145            f.debug_struct("MockHttpSend").finish_non_exhaustive()
1146        }
1147    }
1148
1149    impl MockHttpSend {
1150        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
1151            Self {
1152                calls: Arc::new(AtomicUsize::new(0)),
1153                requests: Arc::new(StdMutex::new(Vec::new())),
1154                responses: Arc::new(StdMutex::new(responses.into_iter().collect())),
1155                gate: None,
1156            }
1157        }
1158
1159        fn gated(
1160            responses: impl IntoIterator<Item = http::Response<Bytes>>,
1161        ) -> (Self, RequestGate) {
1162            let gate = RequestGate {
1163                started: Arc::new(Semaphore::new(0)),
1164                release: Arc::new(Semaphore::new(0)),
1165            };
1166            let mut http = Self::new(responses);
1167            http.gate = Some(gate.clone());
1168            (http, gate)
1169        }
1170
1171        fn requests(&self) -> Vec<CapturedRequest> {
1172            self.requests.lock().expect("lock poisoned").clone()
1173        }
1174    }
1175
1176    impl HttpSend for MockHttpSend {
1177        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1178            self.calls.fetch_add(1, Ordering::SeqCst);
1179            let (parts, body) = request.into_parts();
1180            self.requests
1181                .lock()
1182                .expect("lock poisoned")
1183                .push(CapturedRequest {
1184                    method: parts.method,
1185                    uri: parts.uri,
1186                    headers: parts.headers,
1187                    body: body.to_vec(),
1188                });
1189            if let Some(gate) = &self.gate {
1190                gate.started.add_permits(1);
1191                gate.release
1192                    .acquire()
1193                    .await
1194                    .expect("release semaphore must remain open")
1195                    .forget();
1196            }
1197            self.responses
1198                .lock()
1199                .expect("lock poisoned")
1200                .pop_front()
1201                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
1202        }
1203    }
1204
1205    #[derive(Debug)]
1206    struct SecretTransportError;
1207
1208    impl HttpSend for SecretTransportError {
1209        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1210            Err(
1211                Error::unexpected("transport retained subject_token=source-secret")
1212                    .set_retryable(true),
1213            )
1214        }
1215    }
1216
1217    #[derive(Clone)]
1218    struct FixedCredentialProvider {
1219        credential: Credential,
1220        calls: Arc<AtomicUsize>,
1221    }
1222
1223    impl Debug for FixedCredentialProvider {
1224        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1225            f.debug_struct("FixedCredentialProvider")
1226                .finish_non_exhaustive()
1227        }
1228    }
1229
1230    impl FixedCredentialProvider {
1231        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
1232            let calls = Arc::new(AtomicUsize::new(0));
1233            (
1234                Self {
1235                    credential,
1236                    calls: calls.clone(),
1237                },
1238                calls,
1239            )
1240        }
1241    }
1242
1243    impl ProvideCredential for FixedCredentialProvider {
1244        type Credential = Credential;
1245
1246        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
1247            self.calls.fetch_add(1, Ordering::SeqCst);
1248            Ok(Some(self.credential.clone()))
1249        }
1250    }
1251
1252    fn timestamp(value: &str) -> Timestamp {
1253        value.parse().expect("timestamp must be valid")
1254    }
1255
1256    fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
1257        Credential::with_token(Token {
1258            access_token: access_token.to_string(),
1259            expires_at,
1260        })
1261    }
1262
1263    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
1264        CredentialAccessBoundaryGrant::for_bucket(
1265            "example-bucket",
1266            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1267        )
1268    }
1269
1270    fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
1271        http::Response::builder()
1272            .status(status)
1273            .body(body.into())
1274            .expect("response must build")
1275    }
1276
1277    fn success_response(
1278        intermediary_token: &str,
1279        expires_in: Option<u64>,
1280    ) -> http::Response<Bytes> {
1281        success_response_with_key(
1282            intermediary_token,
1283            GOOGLE_AUTH_LIBRARY_SESSION_KEY,
1284            expires_in,
1285        )
1286    }
1287
1288    fn success_response_with_key(
1289        intermediary_token: &str,
1290        session_key: &str,
1291        expires_in: Option<u64>,
1292    ) -> http::Response<Bytes> {
1293        let mut value = serde_json::json!({
1294            "access_token": intermediary_token,
1295            "issued_token_type": ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
1296            "token_type": "Bearer",
1297            "access_boundary_session_key": session_key,
1298        });
1299        if let Some(expires_in) = expires_in {
1300            value["expires_in"] = expires_in.into();
1301        }
1302        response(
1303            http::StatusCode::OK,
1304            serde_json::to_vec(&value).expect("response JSON must serialize"),
1305        )
1306    }
1307
1308    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
1309        form_urlencoded::parse(&request.body).into_owned().collect()
1310    }
1311
1312    fn output_token(credential: &Credential) -> &Token {
1313        assert!(credential.service_account.is_none());
1314        credential
1315            .token
1316            .as_ref()
1317            .expect("granted credential must contain a token")
1318    }
1319
1320    fn decrypt_restrictions(
1321        credential: &Credential,
1322    ) -> (String, [u8; AES_GCM_NONCE_BYTES], ClientSideAccessBoundary) {
1323        let token = &output_token(credential).access_token;
1324        let (intermediary, encoded_restrictions) = token
1325            .split_once('.')
1326            .expect("client-issued token must contain two parts");
1327        let encrypted = URL_SAFE_NO_PAD
1328            .decode(encoded_restrictions)
1329            .expect("restrictions must be unpadded base64url");
1330        let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1331            .expect("official session key must parse");
1332        assert!(encrypted.starts_with(&key.output_prefix));
1333        let nonce_offset = key.output_prefix.len();
1334        let nonce: [u8; AES_GCM_NONCE_BYTES] = encrypted
1335            [nonce_offset..nonce_offset + AES_GCM_NONCE_BYTES]
1336            .try_into()
1337            .expect("nonce must have the expected length");
1338        let ciphertext = &encrypted[nonce_offset + AES_GCM_NONCE_BYTES..];
1339        let plaintext = match key.key_value.len() {
1340            16 => Aes128Gcm::new_from_slice(&key.key_value)
1341                .expect("AES-128 key must initialize")
1342                .decrypt(&Array(nonce), ciphertext),
1343            32 => Aes256Gcm::new_from_slice(&key.key_value)
1344                .expect("AES-256 key must initialize")
1345                .decrypt(&Array(nonce), ciphertext),
1346            _ => panic!("unexpected test key length"),
1347        }
1348        .expect("restrictions must decrypt");
1349        let restrictions = ClientSideAccessBoundary::decode(plaintext.as_slice())
1350            .expect("restrictions must be a CAB protobuf");
1351        (intermediary.to_string(), nonce, restrictions)
1352    }
1353
1354    fn encoded_tink_keyset(
1355        key_id: u32,
1356        status: i32,
1357        output_prefix_type: i32,
1358        type_url: &str,
1359        material_type: i32,
1360        version: u32,
1361        key_value: Vec<u8>,
1362    ) -> String {
1363        let value = AesGcmKeyProto { version, key_value }.encode_to_vec();
1364        STANDARD.encode(
1365            TinkKeyset {
1366                primary_key_id: key_id,
1367                keys: vec![TinkKeysetKey {
1368                    key_data: Some(TinkKeyData {
1369                        type_url: type_url.to_string(),
1370                        value,
1371                        key_material_type: material_type,
1372                    }),
1373                    status,
1374                    key_id,
1375                    output_prefix_type,
1376                }],
1377            }
1378            .encode_to_vec(),
1379        )
1380    }
1381
1382    #[test]
1383    fn matches_google_auth_library_tink_aes_gcm_vector() {
1384        let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1385            .expect("official session key must parse");
1386        assert_eq!(
1387            key.output_prefix,
1388            [0x01, 0xa1, 0x61, 0x42, 0x76],
1389            "Tink prefix must contain the official primary key id"
1390        );
1391        assert_eq!(
1392            key.key_value.as_slice(),
1393            &[
1394                0xcc, 0x7c, 0xb3, 0x2b, 0xc6, 0x20, 0x61, 0xae, 0xe7, 0x2b, 0xeb, 0x76, 0xaf, 0xc8,
1395                0xd1, 0x0f, 0x59, 0x58, 0x84, 0x75, 0xa2, 0xa2, 0x57, 0x16, 0x70, 0xc5, 0xc4, 0x7b,
1396                0xb0, 0x5f, 0x84, 0x84,
1397            ]
1398        );
1399
1400        let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
1401        let encrypted = key
1402            .encrypt(b"restriction", &nonce)
1403            .expect("vector must encrypt");
1404        assert_eq!(
1405            URL_SAFE_NO_PAD.encode(encrypted),
1406            "AaFhQnYAAQIDBAUGBwgJCgvIc9p0EXthr8WYQl6sKvdE-kCKA-SIx7I0T1E"
1407        );
1408    }
1409
1410    #[test]
1411    fn refresh_lock_registry_does_not_retain_sequential_partitions() {
1412        let state = IntermediaryState::default();
1413        let expires_at = timestamp("2030-01-01T01:00:00Z");
1414        for index in 0..1024 {
1415            let lease = state.refresh_lock(&IntermediaryCacheKey {
1416                endpoint: STS_ENDPOINT,
1417                source_authority: format!("source-{index}"),
1418                source_expires_at: expires_at,
1419            });
1420            assert_eq!(state.refresh_lock_len(), 1);
1421            drop(lease);
1422            assert_eq!(state.refresh_lock_len(), 0);
1423        }
1424    }
1425
1426    #[tokio::test]
1427    async fn sends_exact_official_intermediary_exchange_shape() {
1428        let now = timestamp("2030-01-01T00:00:00Z");
1429        let source_expiry = timestamp("2030-01-01T02:00:00Z");
1430        let http = MockHttpSend::new([success_response("intermediary-token", Some(3600))]);
1431        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1432            .with_time(now)
1433            .with_nonces([[0; AES_GCM_NONCE_BYTES]]);
1434        let source = source_token("source+token/with=reserved", Some(source_expiry));
1435
1436        operation
1437            .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
1438            .await
1439            .expect("client-side grant must succeed");
1440
1441        let requests = http.requests();
1442        assert_eq!(requests.len(), 1);
1443        let request = &requests[0];
1444        assert_eq!(request.method, http::Method::POST);
1445        assert_eq!(request.uri, STS_ENDPOINT);
1446        assert_eq!(request.headers[ACCEPT], "application/json");
1447        assert_eq!(
1448            request.headers[CONTENT_TYPE],
1449            "application/x-www-form-urlencoded"
1450        );
1451        assert!(!request.headers.contains_key(AUTHORIZATION));
1452        assert_eq!(
1453            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
1454            concat!(
1455                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
1456                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3A",
1457                "access_boundary_intermediary_token",
1458                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
1459                "&subject_token=source%2Btoken%2Fwith%3Dreserved"
1460            )
1461        );
1462        let fields = form_fields(request);
1463        assert_eq!(fields.len(), 4);
1464        assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
1465        assert_eq!(
1466            fields["requested_token_type"],
1467            ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
1468        );
1469        assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
1470        assert_eq!(fields["subject_token"], "source+token/with=reserved");
1471        assert!(!fields.contains_key("options"));
1472        assert!(!fields.contains_key("audience"));
1473        assert!(!fields.contains_key("scope"));
1474    }
1475
1476    #[tokio::test]
1477    async fn serializes_typed_prefix_grant_as_compiled_cel_protobuf() {
1478        let now = timestamp("2030-01-01T00:00:00Z");
1479        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
1480            "example-bucket",
1481            "customer \"雪\"/",
1482            CredentialAccessBoundaryPermissions::OBJECT_VIEWER
1483                | CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
1484        );
1485        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1486            .with_time(now)
1487            .with_nonces([[7; AES_GCM_NONCE_BYTES]]);
1488        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1489        let output = operation
1490            .grant_credential(
1491                &Context::new().with_http_send(http),
1492                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1493                None,
1494            )
1495            .await
1496            .expect("client-side grant must succeed");
1497
1498        let (intermediary, nonce, restrictions) = decrypt_restrictions(&output);
1499        assert_eq!(intermediary, "intermediary");
1500        assert_eq!(nonce, [7; AES_GCM_NONCE_BYTES]);
1501        assert_eq!(restrictions.access_boundary_rules.len(), 1);
1502        let rule = &restrictions.access_boundary_rules[0];
1503        assert_eq!(
1504            rule.available_resource,
1505            "//storage.googleapis.com/projects/_/buckets/example-bucket"
1506        );
1507        assert_eq!(
1508            rule.available_permissions,
1509            [
1510                "inRole:roles/storage.objectViewer",
1511                "inRole:roles/storage.objectCreator",
1512            ]
1513        );
1514
1515        let root = rule
1516            .compiled_availability_condition
1517            .as_ref()
1518            .expect("prefix rule must have a compiled condition");
1519        let root_call = root.call_expr.as_ref().expect("root must be a call");
1520        assert_eq!(root_call.function, "_||_");
1521        assert!(root_call.target.is_none());
1522        assert_eq!(root_call.args.len(), 2);
1523
1524        let resource_call = root_call.args[0]
1525            .call_expr
1526            .as_ref()
1527            .expect("first branch must be a call");
1528        assert_eq!(resource_call.function, "startsWith");
1529        let resource_select = resource_call
1530            .target
1531            .as_deref()
1532            .and_then(|expr| expr.select_expr.as_ref())
1533            .expect("resource startsWith target must be a select");
1534        assert_eq!(resource_select.field, "name");
1535        assert_eq!(
1536            resource_select
1537                .operand
1538                .as_deref()
1539                .and_then(|expr| expr.ident_expr.as_ref())
1540                .map(|ident| ident.name.as_str()),
1541            Some("resource")
1542        );
1543        assert_eq!(
1544            resource_call.args[0]
1545                .const_expr
1546                .as_ref()
1547                .and_then(|constant| constant.string_value.as_deref()),
1548            Some("projects/_/buckets/example-bucket/objects/customer \"雪\"/")
1549        );
1550
1551        let list_call = root_call.args[1]
1552            .call_expr
1553            .as_ref()
1554            .expect("second branch must be a call");
1555        assert_eq!(list_call.function, "startsWith");
1556        let get_attribute = list_call
1557            .target
1558            .as_deref()
1559            .and_then(|expr| expr.call_expr.as_ref())
1560            .expect("list startsWith target must call getAttribute");
1561        assert_eq!(get_attribute.function, "getAttribute");
1562        assert_eq!(
1563            get_attribute
1564                .target
1565                .as_deref()
1566                .and_then(|expr| expr.ident_expr.as_ref())
1567                .map(|ident| ident.name.as_str()),
1568            Some("api")
1569        );
1570        assert_eq!(
1571            get_attribute.args[0]
1572                .const_expr
1573                .as_ref()
1574                .and_then(|constant| constant.string_value.as_deref()),
1575            Some("storage.googleapis.com/objectListPrefix")
1576        );
1577        assert_eq!(
1578            get_attribute.args[1]
1579                .const_expr
1580                .as_ref()
1581                .and_then(|constant| constant.string_value.as_deref()),
1582            Some("")
1583        );
1584        assert_eq!(
1585            list_call.args[0]
1586                .const_expr
1587                .as_ref()
1588                .and_then(|constant| constant.string_value.as_deref()),
1589            Some("customer \"雪\"/")
1590        );
1591    }
1592
1593    #[tokio::test]
1594    async fn bucket_wide_and_multiple_rules_preserve_union_order() {
1595        let now = timestamp("2030-01-01T00:00:00Z");
1596        let grant = viewer_bucket_grant().with_object_prefix_rule(
1597            "second-bucket",
1598            "tenant/",
1599            CredentialAccessBoundaryPermissions::OBJECT_USER,
1600        );
1601        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1602            .with_time(now)
1603            .with_nonces([[9; AES_GCM_NONCE_BYTES]]);
1604        let output = operation
1605            .grant_credential(
1606                &Context::new().with_http_send(MockHttpSend::new([success_response(
1607                    "intermediary",
1608                    Some(3600),
1609                )])),
1610                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1611                None,
1612            )
1613            .await
1614            .expect("client-side grant must succeed");
1615
1616        let (_, _, restrictions) = decrypt_restrictions(&output);
1617        assert_eq!(restrictions.access_boundary_rules.len(), 2);
1618        assert_eq!(
1619            restrictions.access_boundary_rules[0].available_resource,
1620            "//storage.googleapis.com/projects/_/buckets/example-bucket"
1621        );
1622        assert!(
1623            restrictions.access_boundary_rules[0]
1624                .compiled_availability_condition
1625                .is_none()
1626        );
1627        assert_eq!(
1628            restrictions.access_boundary_rules[1].available_resource,
1629            "//storage.googleapis.com/projects/_/buckets/second-bucket"
1630        );
1631        assert!(
1632            restrictions.access_boundary_rules[1]
1633                .compiled_availability_condition
1634                .is_some()
1635        );
1636    }
1637
1638    #[tokio::test]
1639    async fn rejects_invalid_grant_before_io() {
1640        let now = timestamp("2030-01-01T00:00:00Z");
1641        let http = MockHttpSend::new([]);
1642        let operation = ClientSideCredentialAccessBoundaryGranter::new(
1643            CredentialAccessBoundaryGrant::for_object_prefix(
1644                "example-bucket",
1645                "",
1646                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1647            ),
1648        )
1649        .with_time(now);
1650        let err = operation
1651            .grant_credential(
1652                &Context::new().with_http_send(http.clone()),
1653                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1654                None,
1655            )
1656            .await
1657            .expect_err("invalid grant must fail");
1658
1659        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1660        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1661    }
1662
1663    #[tokio::test]
1664    async fn rejects_incompatible_sources_and_lifetimes_before_io() {
1665        let now = timestamp("2030-01-01T00:00:00Z");
1666        let http = MockHttpSend::new([]);
1667        let ctx = Context::new().with_http_send(http.clone());
1668        let operation =
1669            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
1670        let valid_token = Token {
1671            access_token: "source".to_string(),
1672            expires_at: Some(now + Duration::from_secs(2 * 60 * 60)),
1673        };
1674        let invalid_sources = [
1675            Credential::with_service_account(ServiceAccount {
1676                private_key: "private".to_string(),
1677                client_email: "service@example.com".to_string(),
1678            }),
1679            Credential {
1680                service_account: Some(ServiceAccount {
1681                    private_key: "private".to_string(),
1682                    client_email: "service@example.com".to_string(),
1683                }),
1684                token: Some(valid_token),
1685            },
1686            source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
1687            source_token("source", None),
1688            source_token("source", Some(now + DEFAULT_MINIMUM_TOKEN_LIFETIME)),
1689        ];
1690        for source in invalid_sources {
1691            let err = operation
1692                .grant_credential(&ctx, &source, None)
1693                .await
1694                .expect_err("incompatible source must fail");
1695            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1696        }
1697
1698        let valid_source = source_token("source", Some(now + Duration::from_secs(13 * 60 * 60)));
1699        for lifetime in [
1700            Duration::ZERO,
1701            MAX_ACCESS_TOKEN_LIFETIME,
1702            MAX_ACCESS_TOKEN_LIFETIME + Duration::from_secs(1),
1703        ] {
1704            let err = operation
1705                .grant_credential(&ctx, &valid_source, Some(lifetime))
1706                .await
1707                .expect_err("invalid minimum lifetime must fail");
1708            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1709        }
1710        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1711    }
1712
1713    #[test]
1714    fn validates_exact_minimum_lifetime_boundary_after_rounding() {
1715        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant());
1716        assert_eq!(
1717            operation
1718                .effective_minimum_lifetime(Some(MAX_MINIMUM_TOKEN_LIFETIME))
1719                .expect("the largest feasible whole-second lifetime must be accepted"),
1720            MAX_MINIMUM_TOKEN_LIFETIME
1721        );
1722
1723        for lifetime in [
1724            MAX_MINIMUM_TOKEN_LIFETIME + Duration::from_nanos(1),
1725            MAX_ACCESS_TOKEN_LIFETIME,
1726            Duration::MAX,
1727        ] {
1728            let err = operation
1729                .effective_minimum_lifetime(Some(lifetime))
1730                .expect_err("a lifetime that rounds to twelve hours must fail");
1731            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1732        }
1733    }
1734
1735    #[tokio::test]
1736    async fn accepts_maximum_intermediary_and_minimum_lifetime_boundaries() {
1737        let request_time = timestamp("2030-01-01T00:00:00Z");
1738        let response_time = timestamp("2030-01-01T00:00:01Z");
1739        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1740            .with_time(request_time)
1741            .with_time_after_request(response_time)
1742            .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1743        let output = operation
1744            .grant_credential(
1745                &Context::new().with_http_send(MockHttpSend::new([success_response(
1746                    "intermediary",
1747                    Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
1748                )])),
1749                &source_token(
1750                    "source",
1751                    Some(request_time + Duration::from_secs(13 * 60 * 60)),
1752                ),
1753                Some(MAX_MINIMUM_TOKEN_LIFETIME),
1754            )
1755            .await
1756            .expect("maximum feasible lifetime must succeed");
1757
1758        assert_eq!(
1759            output_token(&output).expires_at,
1760            Some(timestamp("2030-01-01T12:00:01Z"))
1761        );
1762    }
1763
1764    #[tokio::test]
1765    async fn anchors_clamps_and_requires_intermediary_expiration() {
1766        let request_time = timestamp("2030-01-01T00:00:00Z");
1767        let response_time = timestamp("2030-01-01T00:00:02Z");
1768        let source_expiry = timestamp("2030-01-01T02:00:00Z");
1769        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1770            .with_time(request_time)
1771            .with_time_after_request(response_time)
1772            .with_nonces([
1773                [1; AES_GCM_NONCE_BYTES],
1774                [2; AES_GCM_NONCE_BYTES],
1775                [3; AES_GCM_NONCE_BYTES],
1776            ]);
1777        let source = source_token("source", Some(source_expiry));
1778
1779        let explicit = operation
1780            .grant_credential(
1781                &Context::new().with_http_send(MockHttpSend::new([success_response(
1782                    "explicit",
1783                    Some(3600),
1784                )])),
1785                &source,
1786                None,
1787            )
1788            .await
1789            .expect("explicit expiration must succeed");
1790        assert_eq!(
1791            output_token(&explicit).expires_at,
1792            Some(timestamp("2030-01-01T01:00:02Z"))
1793        );
1794
1795        let missing_expiration_operation =
1796            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1797                .with_time(request_time)
1798                .with_time_after_request(response_time);
1799        let err = missing_expiration_operation
1800            .grant_credential(
1801                &Context::new()
1802                    .with_http_send(MockHttpSend::new([success_response("inherited", None)])),
1803                &source,
1804                None,
1805            )
1806            .await
1807            .expect_err("client-issued CAB requires a service-account STS expiration");
1808        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1809
1810        let clamped_operation =
1811            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1812                .with_time(request_time)
1813                .with_time_after_request(response_time)
1814                .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
1815        let clamped = clamped_operation
1816            .grant_credential(
1817                &Context::new().with_http_send(MockHttpSend::new([success_response(
1818                    "clamped",
1819                    Some(3 * 60 * 60),
1820                )])),
1821                &source,
1822                None,
1823            )
1824            .await
1825            .expect("STS expiration must clamp to source expiration");
1826        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
1827    }
1828
1829    #[tokio::test]
1830    async fn rejects_malformed_expiration_and_sts_responses() {
1831        let now = timestamp("2030-01-01T00:00:00Z");
1832        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1833        let malformed = [
1834            success_response("zero", Some(0)),
1835            success_response("too-long", Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1)),
1836            response(
1837                http::StatusCode::OK,
1838                format!(
1839                    r#"{{"access_token":"negative","issued_token_type":"{ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE}","token_type":"Bearer","expires_in":-1,"access_boundary_session_key":"{GOOGLE_AUTH_LIBRARY_SESSION_KEY}"}}"#
1840                ),
1841            ),
1842            response(
1843                http::StatusCode::OK,
1844                br#"{"access_token":"missing-fields"}"#.as_slice(),
1845            ),
1846            success_response_with_key("bad-key", "not base64!", Some(3600)),
1847        ];
1848
1849        for response in malformed {
1850            let http = MockHttpSend::new([response]);
1851            let err = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1852                .with_time(now)
1853                .grant_credential(&Context::new().with_http_send(http), &source, None)
1854                .await
1855                .expect_err("malformed response must fail");
1856            assert_eq!(err.kind(), ErrorKind::Unexpected);
1857            let debug = format!("{err:?}");
1858            assert!(!debug.contains("bad-key"));
1859            assert!(!debug.contains(GOOGLE_AUTH_LIBRARY_SESSION_KEY));
1860        }
1861    }
1862
1863    #[tokio::test]
1864    async fn validates_tink_keyset_shape_and_algorithm() {
1865        let now = timestamp("2030-01-01T00:00:00Z");
1866        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1867        let keys = [
1868            encoded_tink_keyset(
1869                7,
1870                0,
1871                TINK_PREFIX,
1872                AES_GCM_KEY_TYPE_URL,
1873                TINK_KEY_MATERIAL_SYMMETRIC,
1874                0,
1875                vec![1; 32],
1876            ),
1877            encoded_tink_keyset(
1878                7,
1879                TINK_KEY_STATUS_ENABLED,
1880                0,
1881                AES_GCM_KEY_TYPE_URL,
1882                TINK_KEY_MATERIAL_SYMMETRIC,
1883                0,
1884                vec![1; 32],
1885            ),
1886            encoded_tink_keyset(
1887                7,
1888                TINK_KEY_STATUS_ENABLED,
1889                TINK_PREFIX,
1890                "type.googleapis.com/google.crypto.tink.ChaCha20Poly1305Key",
1891                TINK_KEY_MATERIAL_SYMMETRIC,
1892                0,
1893                vec![1; 32],
1894            ),
1895            encoded_tink_keyset(
1896                7,
1897                TINK_KEY_STATUS_ENABLED,
1898                TINK_PREFIX,
1899                AES_GCM_KEY_TYPE_URL,
1900                0,
1901                0,
1902                vec![1; 32],
1903            ),
1904            encoded_tink_keyset(
1905                7,
1906                TINK_KEY_STATUS_ENABLED,
1907                TINK_PREFIX,
1908                AES_GCM_KEY_TYPE_URL,
1909                TINK_KEY_MATERIAL_SYMMETRIC,
1910                1,
1911                vec![1; 32],
1912            ),
1913            encoded_tink_keyset(
1914                7,
1915                TINK_KEY_STATUS_ENABLED,
1916                TINK_PREFIX,
1917                AES_GCM_KEY_TYPE_URL,
1918                TINK_KEY_MATERIAL_SYMMETRIC,
1919                0,
1920                vec![1; 24],
1921            ),
1922        ];
1923
1924        for key in keys {
1925            let err =
1926                ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1927                    .with_time(now)
1928                    .grant_credential(
1929                        &Context::new().with_http_send(MockHttpSend::new([
1930                            success_response_with_key("intermediary", &key, Some(3600)),
1931                        ])),
1932                        &source,
1933                        None,
1934                    )
1935                    .await
1936                    .expect_err("invalid keyset must fail");
1937            assert_eq!(err.kind(), ErrorKind::Unexpected);
1938            assert!(!format!("{err:?}").contains(&key));
1939        }
1940    }
1941
1942    #[tokio::test]
1943    async fn checks_post_io_and_post_generation_validity() {
1944        let now = timestamp("2030-01-01T00:00:00Z");
1945        let source_expiry = now + Duration::from_secs(60 * 60);
1946
1947        let post_io = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1948            .with_time(now)
1949            .with_time_after_request(now + Duration::from_secs(31 * 60));
1950        let err = post_io
1951            .grant_credential(
1952                &Context::new().with_http_send(MockHttpSend::new([success_response(
1953                    "intermediary",
1954                    Some(3600),
1955                )])),
1956                &source_token("source", Some(source_expiry)),
1957                None,
1958            )
1959            .await
1960            .expect_err("intermediary must cover the minimum after I/O");
1961        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1962
1963        let post_generation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1964            .with_time(now)
1965            .with_time_after_generation(now + Duration::from_secs(30 * 60))
1966            .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1967        let err = post_generation
1968            .grant_credential(
1969                &Context::new().with_http_send(MockHttpSend::new([success_response(
1970                    "intermediary",
1971                    Some(60 * 60),
1972                )])),
1973                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1974                None,
1975            )
1976            .await
1977            .expect_err("output must cover the minimum after local generation");
1978        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1979    }
1980
1981    #[tokio::test]
1982    async fn reuses_intermediary_but_never_caches_outputs() {
1983        let now = timestamp("2030-01-01T00:00:00Z");
1984        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1985        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1986            .with_time(now)
1987            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
1988        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1989        let ctx = Context::new().with_http_send(http.clone());
1990
1991        let first = operation
1992            .grant_credential(&ctx, &source, None)
1993            .await
1994            .expect("first grant must succeed");
1995        let second = operation
1996            .grant_credential(&ctx, &source, None)
1997            .await
1998            .expect("second grant must succeed");
1999
2000        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2001        assert_eq!(operation.cache_len().await, 1);
2002        assert_ne!(
2003            output_token(&first).access_token,
2004            output_token(&second).access_token
2005        );
2006        assert_eq!(
2007            output_token(&first).expires_at,
2008            output_token(&second).expires_at
2009        );
2010        assert_eq!(decrypt_restrictions(&first).1, [1; AES_GCM_NONCE_BYTES]);
2011        assert_eq!(decrypt_restrictions(&second).1, [2; AES_GCM_NONCE_BYTES]);
2012    }
2013
2014    #[tokio::test]
2015    async fn refreshes_only_when_cached_intermediary_cannot_cover_minimum() {
2016        let now = timestamp("2030-01-01T00:00:00Z");
2017        let http = MockHttpSend::new([
2018            success_response("intermediary-1", Some(3600)),
2019            success_response("intermediary-2", Some(3600)),
2020        ]);
2021        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2022            .with_time(now)
2023            .with_nonces([
2024                [1; AES_GCM_NONCE_BYTES],
2025                [2; AES_GCM_NONCE_BYTES],
2026                [3; AES_GCM_NONCE_BYTES],
2027            ]);
2028        let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2029        let ctx = Context::new().with_http_send(http.clone());
2030
2031        let first = operation
2032            .grant_credential(&ctx, &source, None)
2033            .await
2034            .expect("initial grant must succeed");
2035        let still_fresh = operation
2036            .clone()
2037            .with_time(now + Duration::from_secs(20 * 60))
2038            .grant_credential(&ctx, &source, None)
2039            .await
2040            .expect("fresh intermediary must be reused");
2041        let refreshed = operation
2042            .clone()
2043            .with_time(now + Duration::from_secs(31 * 60))
2044            .grant_credential(&ctx, &source, None)
2045            .await
2046            .expect("near-expiry intermediary must refresh");
2047
2048        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2049        assert_eq!(decrypt_restrictions(&first).0, "intermediary-1");
2050        assert_eq!(decrypt_restrictions(&still_fresh).0, "intermediary-1");
2051        assert_eq!(decrypt_restrictions(&refreshed).0, "intermediary-2");
2052    }
2053
2054    #[tokio::test]
2055    async fn failed_refresh_never_returns_insufficient_cached_output() {
2056        let now = timestamp("2030-01-01T00:00:00Z");
2057        let http = MockHttpSend::new([
2058            success_response("intermediary-1", Some(3600)),
2059            response(
2060                http::StatusCode::SERVICE_UNAVAILABLE,
2061                r#"{"error":"backend_error","error_description":"do not return stale material"}"#,
2062            ),
2063        ]);
2064        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2065            .with_time(now)
2066            .with_nonces([[1; AES_GCM_NONCE_BYTES]]);
2067        let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2068        let ctx = Context::new().with_http_send(http.clone());
2069        operation
2070            .grant_credential(&ctx, &source, None)
2071            .await
2072            .expect("initial grant must succeed");
2073
2074        let err = operation
2075            .clone()
2076            .with_time(now + Duration::from_secs(31 * 60))
2077            .grant_credential(&ctx, &source, None)
2078            .await
2079            .expect_err("failed refresh must not return cached output");
2080        assert_eq!(err.kind(), ErrorKind::Unexpected);
2081        assert!(err.is_retryable());
2082        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2083        assert!(!format!("{err:?}").contains("do not return stale material"));
2084    }
2085
2086    #[tokio::test]
2087    async fn partitions_and_bounds_intermediary_cache() {
2088        let now = timestamp("2030-01-01T00:00:00Z");
2089        let responses = (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2090            .map(|index| success_response(&format!("intermediary-{index}"), Some(3600)))
2091            .collect::<Vec<_>>();
2092        let http = MockHttpSend::new(responses);
2093        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2094            .with_time(now)
2095            .with_nonces(
2096                (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2097                    .map(|index| [index as u8; AES_GCM_NONCE_BYTES]),
2098            );
2099        let ctx = Context::new().with_http_send(http.clone());
2100        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
2101
2102        for index in 0..=INTERMEDIARY_CACHE_CAPACITY {
2103            operation
2104                .grant_credential(
2105                    &ctx,
2106                    &source_token(&format!("source-{index}"), Some(source_expiry)),
2107                    None,
2108                )
2109                .await
2110                .expect("partitioned grant must succeed");
2111        }
2112        assert_eq!(
2113            http.calls.load(Ordering::SeqCst),
2114            INTERMEDIARY_CACHE_CAPACITY + 1
2115        );
2116        assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2117
2118        operation
2119            .grant_credential(&ctx, &source_token("source-0", Some(source_expiry)), None)
2120            .await
2121            .expect("oldest authority must be fetched after eviction");
2122        assert_eq!(
2123            http.calls.load(Ordering::SeqCst),
2124            INTERMEDIARY_CACHE_CAPACITY + 2
2125        );
2126        assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2127
2128        let a = IntermediaryCacheKey {
2129            endpoint: STS_ENDPOINT,
2130            source_authority: "authority".to_string(),
2131            source_expires_at: source_expiry,
2132        };
2133        let b = IntermediaryCacheKey {
2134            endpoint: "https://sts.example.invalid/v1/token",
2135            source_authority: "authority".to_string(),
2136            source_expires_at: source_expiry,
2137        };
2138        assert!(a != b, "endpoint identity must partition cache keys");
2139    }
2140
2141    #[tokio::test]
2142    async fn source_authority_partition_reuses_each_matching_entry() {
2143        let now = timestamp("2030-01-01T00:00:00Z");
2144        let http = MockHttpSend::new([
2145            success_response("intermediary-a", Some(3600)),
2146            success_response("intermediary-b", Some(3600)),
2147        ]);
2148        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2149            .with_time(now)
2150            .with_nonces([
2151                [1; AES_GCM_NONCE_BYTES],
2152                [2; AES_GCM_NONCE_BYTES],
2153                [3; AES_GCM_NONCE_BYTES],
2154            ]);
2155        let ctx = Context::new().with_http_send(http.clone());
2156        let expiry = now + Duration::from_secs(2 * 60 * 60);
2157        let source_a = source_token("source-a", Some(expiry));
2158        let source_b = source_token("source-b", Some(expiry));
2159
2160        let first_a = operation
2161            .grant_credential(&ctx, &source_a, None)
2162            .await
2163            .expect("source A must succeed");
2164        let output_b = operation
2165            .grant_credential(&ctx, &source_b, None)
2166            .await
2167            .expect("source B must succeed");
2168        let second_a = operation
2169            .grant_credential(&ctx, &source_a, None)
2170            .await
2171            .expect("source A cache entry must be reused");
2172
2173        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2174        assert_eq!(operation.cache_len().await, 2);
2175        assert_eq!(decrypt_restrictions(&first_a).0, "intermediary-a");
2176        assert_eq!(decrypt_restrictions(&output_b).0, "intermediary-b");
2177        assert_eq!(decrypt_restrictions(&second_a).0, "intermediary-a");
2178    }
2179
2180    #[tokio::test]
2181    async fn unrelated_source_partitions_refresh_independently() {
2182        let now = timestamp("2030-01-01T00:00:00Z");
2183        let (http, gate) = MockHttpSend::gated([
2184            success_response("intermediary-a", Some(3600)),
2185            success_response("intermediary-b", Some(3600)),
2186        ]);
2187        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2188            .with_time(now)
2189            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2190        let ctx = Context::new().with_http_send(http.clone());
2191        let expiry = now + Duration::from_secs(2 * 60 * 60);
2192
2193        let first = tokio::spawn({
2194            let operation = operation.clone();
2195            let ctx = ctx.clone();
2196            async move {
2197                operation
2198                    .grant_credential(&ctx, &source_token("source-a", Some(expiry)), None)
2199                    .await
2200            }
2201        });
2202        gate.wait_started().await;
2203
2204        let second = tokio::spawn({
2205            let operation = operation.clone();
2206            let ctx = ctx.clone();
2207            async move {
2208                operation
2209                    .grant_credential(&ctx, &source_token("source-b", Some(expiry)), None)
2210                    .await
2211            }
2212        });
2213        let second_started =
2214            tokio::time::timeout(Duration::from_secs(1), gate.wait_started()).await;
2215        gate.release_one();
2216        gate.release_one();
2217        assert!(
2218            second_started.is_ok(),
2219            "an unrelated source partition must not wait for the first STS exchange"
2220        );
2221
2222        first
2223            .await
2224            .expect("first grant task must not panic")
2225            .expect("first source partition must succeed");
2226        second
2227            .await
2228            .expect("second grant task must not panic")
2229            .expect("second source partition must succeed");
2230        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2231        assert_eq!(operation.cache_len().await, 2);
2232        assert_eq!(operation.refresh_lock_len(), 0);
2233    }
2234
2235    #[tokio::test]
2236    async fn concurrent_grants_share_successful_intermediary_refresh() {
2237        let now = timestamp("2030-01-01T00:00:00Z");
2238        let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2239        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2240            .with_time(now)
2241            .with_nonces((0..8).map(|index| [index; AES_GCM_NONCE_BYTES]));
2242        let ctx = Context::new().with_http_send(http.clone());
2243        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2244        let mut tasks = Vec::new();
2245        for _ in 0..8 {
2246            let operation = operation.clone();
2247            let ctx = ctx.clone();
2248            let source = source.clone();
2249            tasks.push(tokio::spawn(async move {
2250                operation.grant_credential(&ctx, &source, None).await
2251            }));
2252        }
2253
2254        gate.wait_started().await;
2255        for _ in 0..4 {
2256            tokio::task::yield_now().await;
2257        }
2258        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2259        gate.release_one();
2260
2261        let mut tokens = HashSet::new();
2262        for task in tasks {
2263            let output = task
2264                .await
2265                .expect("grant task must not panic")
2266                .expect("concurrent grant must succeed");
2267            tokens.insert(output_token(&output).access_token.clone());
2268        }
2269        assert_eq!(tokens.len(), 8);
2270        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2271        assert_eq!(operation.cache_len().await, 1);
2272        assert_eq!(operation.refresh_lock_len(), 0);
2273    }
2274
2275    #[tokio::test]
2276    async fn waiter_retries_after_serialized_refresh_failure() {
2277        let now = timestamp("2030-01-01T00:00:00Z");
2278        let (http, gate) = MockHttpSend::gated([
2279            response(
2280                http::StatusCode::SERVICE_UNAVAILABLE,
2281                r#"{"error":"backend_error"}"#,
2282            ),
2283            success_response("intermediary", Some(3600)),
2284        ]);
2285        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2286            .with_time(now)
2287            .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
2288        let ctx = Context::new().with_http_send(http.clone());
2289        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2290
2291        let leader = tokio::spawn({
2292            let operation = operation.clone();
2293            let ctx = ctx.clone();
2294            let source = source.clone();
2295            async move { operation.grant_credential(&ctx, &source, None).await }
2296        });
2297        gate.wait_started().await;
2298        let waiter = tokio::spawn({
2299            let operation = operation.clone();
2300            let ctx = ctx.clone();
2301            async move { operation.grant_credential(&ctx, &source, None).await }
2302        });
2303        for _ in 0..4 {
2304            tokio::task::yield_now().await;
2305        }
2306        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2307
2308        gate.release_one();
2309        let err = leader
2310            .await
2311            .expect("leader task must not panic")
2312            .expect_err("leader refresh must fail");
2313        assert_eq!(err.kind(), ErrorKind::Unexpected);
2314        assert!(err.is_retryable());
2315
2316        gate.wait_started().await;
2317        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2318        gate.release_one();
2319        let output = waiter
2320            .await
2321            .expect("waiter task must not panic")
2322            .expect("waiter must retry and succeed");
2323        assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2324        assert_eq!(operation.cache_len().await, 1);
2325        assert_eq!(operation.refresh_lock_len(), 0);
2326    }
2327
2328    #[tokio::test]
2329    async fn cancelled_refresh_releases_partition_lock_without_caching_partial_state() {
2330        let now = timestamp("2030-01-01T00:00:00Z");
2331        let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2332        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2333            .with_time(now)
2334            .with_nonces([[5; AES_GCM_NONCE_BYTES]]);
2335        let ctx = Context::new().with_http_send(http.clone());
2336        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2337
2338        let cancelled = {
2339            let operation = operation.clone();
2340            let ctx = ctx.clone();
2341            let source = source.clone();
2342            tokio::spawn(async move { operation.grant_credential(&ctx, &source, None).await })
2343        };
2344        gate.wait_started().await;
2345        cancelled.abort();
2346        assert!(
2347            cancelled
2348                .await
2349                .expect_err("task must be cancelled")
2350                .is_cancelled()
2351        );
2352        assert_eq!(operation.cache_len().await, 0);
2353        assert_eq!(operation.refresh_lock_len(), 0);
2354
2355        gate.release_one();
2356        let output = tokio::time::timeout(
2357            Duration::from_secs(2),
2358            operation.grant_credential(&ctx, &source, None),
2359        )
2360        .await
2361        .expect("retry must not deadlock")
2362        .expect("retry after cancellation must succeed");
2363        assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2364        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2365        assert_eq!(operation.cache_len().await, 1);
2366        assert_eq!(operation.refresh_lock_len(), 0);
2367    }
2368
2369    #[tokio::test]
2370    async fn with_grant_shares_intermediary_for_distinct_authorization() {
2371        let now = timestamp("2030-01-01T00:00:00Z");
2372        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2373        let bucket = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2374            .with_time(now)
2375            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2376        let prefix = bucket
2377            .clone()
2378            .with_grant(CredentialAccessBoundaryGrant::for_object_prefix(
2379                "example-bucket",
2380                "tenant/",
2381                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
2382            ));
2383        let ctx = Context::new().with_http_send(http.clone());
2384        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2385
2386        let bucket_output = bucket
2387            .grant_credential(&ctx, &source, None)
2388            .await
2389            .expect("bucket grant must succeed");
2390        let prefix_output = prefix
2391            .grant_credential(&ctx, &source, None)
2392            .await
2393            .expect("prefix grant must succeed");
2394        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2395        assert!(
2396            decrypt_restrictions(&bucket_output).2.access_boundary_rules[0]
2397                .compiled_availability_condition
2398                .is_none()
2399        );
2400        assert!(
2401            decrypt_restrictions(&prefix_output).2.access_boundary_rules[0]
2402                .compiled_availability_condition
2403                .is_some()
2404        );
2405    }
2406
2407    #[tokio::test]
2408    async fn granter_lifecycle_caches_source_and_intermediary_but_not_outputs() {
2409        let now = Timestamp::now();
2410        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2411        let (provider, provider_calls) = FixedCredentialProvider::new(source);
2412        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2413        let granter = Granter::new(
2414            Context::new().with_http_send(http.clone()),
2415            provider,
2416            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2417                .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]),
2418        );
2419
2420        let first = granter.grant(None).await.expect("first grant must succeed");
2421        let second = granter
2422            .grant(None)
2423            .await
2424            .expect("second grant must succeed");
2425        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
2426        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2427        assert_ne!(
2428            output_token(&first).access_token,
2429            output_token(&second).access_token
2430        );
2431    }
2432
2433    #[tokio::test]
2434    async fn generated_token_is_consumed_by_existing_google_signer() {
2435        let now = Timestamp::now();
2436        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2437            .with_nonces([[8; AES_GCM_NONCE_BYTES]]);
2438        let output = operation
2439            .grant_credential(
2440                &Context::new().with_http_send(MockHttpSend::new([success_response(
2441                    "intermediary",
2442                    Some(3600),
2443                )])),
2444                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
2445                None,
2446            )
2447            .await
2448            .expect("client-side grant must succeed");
2449        let expected = format!("Bearer {}", output_token(&output).access_token);
2450        let (provider, _) = FixedCredentialProvider::new(output);
2451        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
2452        let mut parts =
2453            http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
2454                .body(())
2455                .expect("request must build")
2456                .into_parts()
2457                .0;
2458
2459        signer
2460            .sign(&mut parts, None)
2461            .await
2462            .expect("existing signer must consume client-issued CAB token");
2463        assert_eq!(parts.headers[AUTHORIZATION], expected);
2464        assert!(parts.headers[AUTHORIZATION].is_sensitive());
2465    }
2466
2467    #[tokio::test]
2468    async fn sts_and_transport_errors_are_semantic_and_redacted() {
2469        let now = timestamp("2030-01-01T00:00:00Z");
2470        let source = source_token(
2471            "source-secret",
2472            Some(now + Duration::from_secs(2 * 60 * 60)),
2473        );
2474        let operation =
2475            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
2476        let err = operation
2477            .grant_credential(
2478                &Context::new().with_http_send(MockHttpSend::new([response(
2479                    http::StatusCode::BAD_REQUEST,
2480                    r#"{"error":"invalid_grant","error_description":"source-secret raw-response-secret"}"#,
2481                )])),
2482                &source,
2483                None,
2484            )
2485            .await
2486            .expect_err("STS error must fail");
2487        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
2488        assert!(format!("{err:?}").contains("invalid_grant"));
2489        assert!(!format!("{err:?}").contains("source-secret"));
2490        assert!(!format!("{err:?}").contains("raw-response-secret"));
2491
2492        let err = operation
2493            .grant_credential(
2494                &Context::new().with_http_send(SecretTransportError),
2495                &source,
2496                None,
2497            )
2498            .await
2499            .expect_err("transport error must fail");
2500        assert_eq!(err.kind(), ErrorKind::Unexpected);
2501        assert!(err.is_retryable());
2502        assert!(!format!("{err:?}").contains("source-secret"));
2503        assert!(!format!("{err:?}").contains("transport retained"));
2504    }
2505
2506    #[tokio::test]
2507    async fn debug_redacts_grant_request_and_all_credential_material() {
2508        let now = timestamp("2030-01-01T00:00:00Z");
2509        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
2510            "sensitive-bucket",
2511            "sensitive/prefix",
2512            CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
2513        );
2514        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant.clone())
2515            .with_time(now)
2516            .with_nonces([[6; AES_GCM_NONCE_BYTES]]);
2517        let source = source_token(
2518            "source-secret",
2519            Some(now + Duration::from_secs(2 * 60 * 60)),
2520        );
2521        let http = MockHttpSend::new([success_response("intermediary-secret", Some(3600))]);
2522        let output = operation
2523            .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
2524            .await
2525            .expect("client-side grant must succeed");
2526        let request = http
2527            .requests()
2528            .into_iter()
2529            .next()
2530            .expect("request must be captured");
2531        let key =
2532            TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY).expect("official key must parse");
2533
2534        for (debug, secret) in [
2535            (format!("{grant:?}"), "sensitive-bucket"),
2536            (format!("{grant:?}"), "sensitive/prefix"),
2537            (format!("{operation:?}"), "sensitive-bucket"),
2538            (format!("{source:?}"), "source-secret"),
2539            (format!("{request:?}"), "source-secret"),
2540            (format!("{output:?}"), "intermediary-secret"),
2541            (format!("{key:?}"), "cc7c"),
2542            (
2543                format!("{:?}", CredentialAccessBoundaryPermissions::OBJECT_ADMIN),
2544                "storage.objectAdmin",
2545            ),
2546        ] {
2547            assert!(!debug.contains(secret), "{debug}");
2548        }
2549        assert_eq!(
2550            format!("{operation:?}"),
2551            "ClientSideCredentialAccessBoundaryGranter { .. }"
2552        );
2553    }
2554}