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 asyncband::mutex::Mutex;
26use base64::Engine;
27use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
28use form_urlencoded::Serializer;
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 asyncband::semaphore::Semaphore;
1081    use bytes::Bytes;
1082    use http::header::{AUTHORIZATION, HeaderMap};
1083    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer, time::Timestamp};
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.acquire(1).await.forget();
1124        }
1125
1126        fn release_one(&self) {
1127            self.release.release(1);
1128        }
1129    }
1130
1131    #[derive(Clone)]
1132    struct MockHttpSend {
1133        calls: Arc<AtomicUsize>,
1134        requests: Arc<StdMutex<Vec<CapturedRequest>>>,
1135        responses: Arc<StdMutex<VecDeque<http::Response<Bytes>>>>,
1136        gate: Option<RequestGate>,
1137    }
1138
1139    impl Debug for MockHttpSend {
1140        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1141            f.debug_struct("MockHttpSend").finish_non_exhaustive()
1142        }
1143    }
1144
1145    impl MockHttpSend {
1146        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
1147            Self {
1148                calls: Arc::new(AtomicUsize::new(0)),
1149                requests: Arc::new(StdMutex::new(Vec::new())),
1150                responses: Arc::new(StdMutex::new(responses.into_iter().collect())),
1151                gate: None,
1152            }
1153        }
1154
1155        fn gated(
1156            responses: impl IntoIterator<Item = http::Response<Bytes>>,
1157        ) -> (Self, RequestGate) {
1158            let gate = RequestGate {
1159                started: Arc::new(Semaphore::new(0)),
1160                release: Arc::new(Semaphore::new(0)),
1161            };
1162            let mut http = Self::new(responses);
1163            http.gate = Some(gate.clone());
1164            (http, gate)
1165        }
1166
1167        fn requests(&self) -> Vec<CapturedRequest> {
1168            self.requests.lock().expect("lock poisoned").clone()
1169        }
1170    }
1171
1172    impl HttpSend for MockHttpSend {
1173        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1174            self.calls.fetch_add(1, Ordering::SeqCst);
1175            let (parts, body) = request.into_parts();
1176            self.requests
1177                .lock()
1178                .expect("lock poisoned")
1179                .push(CapturedRequest {
1180                    method: parts.method,
1181                    uri: parts.uri,
1182                    headers: parts.headers,
1183                    body: body.to_vec(),
1184                });
1185            if let Some(gate) = &self.gate {
1186                gate.started.release(1);
1187                gate.release.acquire(1).await.forget();
1188            }
1189            self.responses
1190                .lock()
1191                .expect("lock poisoned")
1192                .pop_front()
1193                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
1194        }
1195    }
1196
1197    #[derive(Debug)]
1198    struct SecretTransportError;
1199
1200    impl HttpSend for SecretTransportError {
1201        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1202            Err(
1203                Error::unexpected("transport retained subject_token=source-secret")
1204                    .set_retryable(true),
1205            )
1206        }
1207    }
1208
1209    #[derive(Clone)]
1210    struct FixedCredentialProvider {
1211        credential: Credential,
1212        calls: Arc<AtomicUsize>,
1213    }
1214
1215    impl Debug for FixedCredentialProvider {
1216        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1217            f.debug_struct("FixedCredentialProvider")
1218                .finish_non_exhaustive()
1219        }
1220    }
1221
1222    impl FixedCredentialProvider {
1223        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
1224            let calls = Arc::new(AtomicUsize::new(0));
1225            (
1226                Self {
1227                    credential,
1228                    calls: calls.clone(),
1229                },
1230                calls,
1231            )
1232        }
1233    }
1234
1235    impl ProvideCredential for FixedCredentialProvider {
1236        type Credential = Credential;
1237
1238        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
1239            self.calls.fetch_add(1, Ordering::SeqCst);
1240            Ok(Some(self.credential.clone()))
1241        }
1242    }
1243
1244    fn timestamp(value: &str) -> Timestamp {
1245        value.parse().expect("timestamp must be valid")
1246    }
1247
1248    fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
1249        Credential::with_token(Token {
1250            access_token: access_token.to_string(),
1251            expires_at,
1252        })
1253    }
1254
1255    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
1256        CredentialAccessBoundaryGrant::for_bucket(
1257            "example-bucket",
1258            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1259        )
1260    }
1261
1262    fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
1263        http::Response::builder()
1264            .status(status)
1265            .body(body.into())
1266            .expect("response must build")
1267    }
1268
1269    fn success_response(
1270        intermediary_token: &str,
1271        expires_in: Option<u64>,
1272    ) -> http::Response<Bytes> {
1273        success_response_with_key(
1274            intermediary_token,
1275            GOOGLE_AUTH_LIBRARY_SESSION_KEY,
1276            expires_in,
1277        )
1278    }
1279
1280    fn success_response_with_key(
1281        intermediary_token: &str,
1282        session_key: &str,
1283        expires_in: Option<u64>,
1284    ) -> http::Response<Bytes> {
1285        let mut value = serde_json::json!({
1286            "access_token": intermediary_token,
1287            "issued_token_type": ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
1288            "token_type": "Bearer",
1289            "access_boundary_session_key": session_key,
1290        });
1291        if let Some(expires_in) = expires_in {
1292            value["expires_in"] = expires_in.into();
1293        }
1294        response(
1295            http::StatusCode::OK,
1296            serde_json::to_vec(&value).expect("response JSON must serialize"),
1297        )
1298    }
1299
1300    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
1301        form_urlencoded::parse(&request.body).into_owned().collect()
1302    }
1303
1304    fn output_token(credential: &Credential) -> &Token {
1305        assert!(credential.service_account.is_none());
1306        credential
1307            .token
1308            .as_ref()
1309            .expect("granted credential must contain a token")
1310    }
1311
1312    fn decrypt_restrictions(
1313        credential: &Credential,
1314    ) -> (String, [u8; AES_GCM_NONCE_BYTES], ClientSideAccessBoundary) {
1315        let token = &output_token(credential).access_token;
1316        let (intermediary, encoded_restrictions) = token
1317            .split_once('.')
1318            .expect("client-issued token must contain two parts");
1319        let encrypted = URL_SAFE_NO_PAD
1320            .decode(encoded_restrictions)
1321            .expect("restrictions must be unpadded base64url");
1322        let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1323            .expect("official session key must parse");
1324        assert!(encrypted.starts_with(&key.output_prefix));
1325        let nonce_offset = key.output_prefix.len();
1326        let nonce: [u8; AES_GCM_NONCE_BYTES] = encrypted
1327            [nonce_offset..nonce_offset + AES_GCM_NONCE_BYTES]
1328            .try_into()
1329            .expect("nonce must have the expected length");
1330        let ciphertext = &encrypted[nonce_offset + AES_GCM_NONCE_BYTES..];
1331        let plaintext = match key.key_value.len() {
1332            16 => Aes128Gcm::new_from_slice(&key.key_value)
1333                .expect("AES-128 key must initialize")
1334                .decrypt(&Array(nonce), ciphertext),
1335            32 => Aes256Gcm::new_from_slice(&key.key_value)
1336                .expect("AES-256 key must initialize")
1337                .decrypt(&Array(nonce), ciphertext),
1338            _ => panic!("unexpected test key length"),
1339        }
1340        .expect("restrictions must decrypt");
1341        let restrictions = ClientSideAccessBoundary::decode(plaintext.as_slice())
1342            .expect("restrictions must be a CAB protobuf");
1343        (intermediary.to_string(), nonce, restrictions)
1344    }
1345
1346    fn encoded_tink_keyset(
1347        key_id: u32,
1348        status: i32,
1349        output_prefix_type: i32,
1350        type_url: &str,
1351        material_type: i32,
1352        version: u32,
1353        key_value: Vec<u8>,
1354    ) -> String {
1355        let value = AesGcmKeyProto { version, key_value }.encode_to_vec();
1356        STANDARD.encode(
1357            TinkKeyset {
1358                primary_key_id: key_id,
1359                keys: vec![TinkKeysetKey {
1360                    key_data: Some(TinkKeyData {
1361                        type_url: type_url.to_string(),
1362                        value,
1363                        key_material_type: material_type,
1364                    }),
1365                    status,
1366                    key_id,
1367                    output_prefix_type,
1368                }],
1369            }
1370            .encode_to_vec(),
1371        )
1372    }
1373
1374    #[test]
1375    fn matches_google_auth_library_tink_aes_gcm_vector() {
1376        let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1377            .expect("official session key must parse");
1378        assert_eq!(
1379            key.output_prefix,
1380            [0x01, 0xa1, 0x61, 0x42, 0x76],
1381            "Tink prefix must contain the official primary key id"
1382        );
1383        assert_eq!(
1384            key.key_value.as_slice(),
1385            &[
1386                0xcc, 0x7c, 0xb3, 0x2b, 0xc6, 0x20, 0x61, 0xae, 0xe7, 0x2b, 0xeb, 0x76, 0xaf, 0xc8,
1387                0xd1, 0x0f, 0x59, 0x58, 0x84, 0x75, 0xa2, 0xa2, 0x57, 0x16, 0x70, 0xc5, 0xc4, 0x7b,
1388                0xb0, 0x5f, 0x84, 0x84,
1389            ]
1390        );
1391
1392        let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
1393        let encrypted = key
1394            .encrypt(b"restriction", &nonce)
1395            .expect("vector must encrypt");
1396        assert_eq!(
1397            URL_SAFE_NO_PAD.encode(encrypted),
1398            "AaFhQnYAAQIDBAUGBwgJCgvIc9p0EXthr8WYQl6sKvdE-kCKA-SIx7I0T1E"
1399        );
1400    }
1401
1402    #[test]
1403    fn refresh_lock_registry_does_not_retain_sequential_partitions() {
1404        let state = IntermediaryState::default();
1405        let expires_at = timestamp("2030-01-01T01:00:00Z");
1406        for index in 0..1024 {
1407            let lease = state.refresh_lock(&IntermediaryCacheKey {
1408                endpoint: STS_ENDPOINT,
1409                source_authority: format!("source-{index}"),
1410                source_expires_at: expires_at,
1411            });
1412            assert_eq!(state.refresh_lock_len(), 1);
1413            drop(lease);
1414            assert_eq!(state.refresh_lock_len(), 0);
1415        }
1416    }
1417
1418    #[tokio::test]
1419    async fn sends_exact_official_intermediary_exchange_shape() {
1420        let now = timestamp("2030-01-01T00:00:00Z");
1421        let source_expiry = timestamp("2030-01-01T02:00:00Z");
1422        let http = MockHttpSend::new([success_response("intermediary-token", Some(3600))]);
1423        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1424            .with_time(now)
1425            .with_nonces([[0; AES_GCM_NONCE_BYTES]]);
1426        let source = source_token("source+token/with=reserved", Some(source_expiry));
1427
1428        operation
1429            .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
1430            .await
1431            .expect("client-side grant must succeed");
1432
1433        let requests = http.requests();
1434        assert_eq!(requests.len(), 1);
1435        let request = &requests[0];
1436        assert_eq!(request.method, http::Method::POST);
1437        assert_eq!(request.uri, STS_ENDPOINT);
1438        assert_eq!(request.headers[ACCEPT], "application/json");
1439        assert_eq!(
1440            request.headers[CONTENT_TYPE],
1441            "application/x-www-form-urlencoded"
1442        );
1443        assert!(!request.headers.contains_key(AUTHORIZATION));
1444        assert_eq!(
1445            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
1446            concat!(
1447                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
1448                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3A",
1449                "access_boundary_intermediary_token",
1450                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
1451                "&subject_token=source%2Btoken%2Fwith%3Dreserved"
1452            )
1453        );
1454        let fields = form_fields(request);
1455        assert_eq!(fields.len(), 4);
1456        assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
1457        assert_eq!(
1458            fields["requested_token_type"],
1459            ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
1460        );
1461        assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
1462        assert_eq!(fields["subject_token"], "source+token/with=reserved");
1463        assert!(!fields.contains_key("options"));
1464        assert!(!fields.contains_key("audience"));
1465        assert!(!fields.contains_key("scope"));
1466    }
1467
1468    #[tokio::test]
1469    async fn serializes_typed_prefix_grant_as_compiled_cel_protobuf() {
1470        let now = timestamp("2030-01-01T00:00:00Z");
1471        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
1472            "example-bucket",
1473            "customer \"雪\"/",
1474            CredentialAccessBoundaryPermissions::OBJECT_VIEWER
1475                | CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
1476        );
1477        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1478            .with_time(now)
1479            .with_nonces([[7; AES_GCM_NONCE_BYTES]]);
1480        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1481        let output = operation
1482            .grant_credential(
1483                &Context::new().with_http_send(http),
1484                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1485                None,
1486            )
1487            .await
1488            .expect("client-side grant must succeed");
1489
1490        let (intermediary, nonce, restrictions) = decrypt_restrictions(&output);
1491        assert_eq!(intermediary, "intermediary");
1492        assert_eq!(nonce, [7; AES_GCM_NONCE_BYTES]);
1493        assert_eq!(restrictions.access_boundary_rules.len(), 1);
1494        let rule = &restrictions.access_boundary_rules[0];
1495        assert_eq!(
1496            rule.available_resource,
1497            "//storage.googleapis.com/projects/_/buckets/example-bucket"
1498        );
1499        assert_eq!(
1500            rule.available_permissions,
1501            [
1502                "inRole:roles/storage.objectViewer",
1503                "inRole:roles/storage.objectCreator",
1504            ]
1505        );
1506
1507        let root = rule
1508            .compiled_availability_condition
1509            .as_ref()
1510            .expect("prefix rule must have a compiled condition");
1511        let root_call = root.call_expr.as_ref().expect("root must be a call");
1512        assert_eq!(root_call.function, "_||_");
1513        assert!(root_call.target.is_none());
1514        assert_eq!(root_call.args.len(), 2);
1515
1516        let resource_call = root_call.args[0]
1517            .call_expr
1518            .as_ref()
1519            .expect("first branch must be a call");
1520        assert_eq!(resource_call.function, "startsWith");
1521        let resource_select = resource_call
1522            .target
1523            .as_deref()
1524            .and_then(|expr| expr.select_expr.as_ref())
1525            .expect("resource startsWith target must be a select");
1526        assert_eq!(resource_select.field, "name");
1527        assert_eq!(
1528            resource_select
1529                .operand
1530                .as_deref()
1531                .and_then(|expr| expr.ident_expr.as_ref())
1532                .map(|ident| ident.name.as_str()),
1533            Some("resource")
1534        );
1535        assert_eq!(
1536            resource_call.args[0]
1537                .const_expr
1538                .as_ref()
1539                .and_then(|constant| constant.string_value.as_deref()),
1540            Some("projects/_/buckets/example-bucket/objects/customer \"雪\"/")
1541        );
1542
1543        let list_call = root_call.args[1]
1544            .call_expr
1545            .as_ref()
1546            .expect("second branch must be a call");
1547        assert_eq!(list_call.function, "startsWith");
1548        let get_attribute = list_call
1549            .target
1550            .as_deref()
1551            .and_then(|expr| expr.call_expr.as_ref())
1552            .expect("list startsWith target must call getAttribute");
1553        assert_eq!(get_attribute.function, "getAttribute");
1554        assert_eq!(
1555            get_attribute
1556                .target
1557                .as_deref()
1558                .and_then(|expr| expr.ident_expr.as_ref())
1559                .map(|ident| ident.name.as_str()),
1560            Some("api")
1561        );
1562        assert_eq!(
1563            get_attribute.args[0]
1564                .const_expr
1565                .as_ref()
1566                .and_then(|constant| constant.string_value.as_deref()),
1567            Some("storage.googleapis.com/objectListPrefix")
1568        );
1569        assert_eq!(
1570            get_attribute.args[1]
1571                .const_expr
1572                .as_ref()
1573                .and_then(|constant| constant.string_value.as_deref()),
1574            Some("")
1575        );
1576        assert_eq!(
1577            list_call.args[0]
1578                .const_expr
1579                .as_ref()
1580                .and_then(|constant| constant.string_value.as_deref()),
1581            Some("customer \"雪\"/")
1582        );
1583    }
1584
1585    #[tokio::test]
1586    async fn bucket_wide_and_multiple_rules_preserve_union_order() {
1587        let now = timestamp("2030-01-01T00:00:00Z");
1588        let grant = viewer_bucket_grant().with_object_prefix_rule(
1589            "second-bucket",
1590            "tenant/",
1591            CredentialAccessBoundaryPermissions::OBJECT_USER,
1592        );
1593        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1594            .with_time(now)
1595            .with_nonces([[9; AES_GCM_NONCE_BYTES]]);
1596        let output = operation
1597            .grant_credential(
1598                &Context::new().with_http_send(MockHttpSend::new([success_response(
1599                    "intermediary",
1600                    Some(3600),
1601                )])),
1602                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1603                None,
1604            )
1605            .await
1606            .expect("client-side grant must succeed");
1607
1608        let (_, _, restrictions) = decrypt_restrictions(&output);
1609        assert_eq!(restrictions.access_boundary_rules.len(), 2);
1610        assert_eq!(
1611            restrictions.access_boundary_rules[0].available_resource,
1612            "//storage.googleapis.com/projects/_/buckets/example-bucket"
1613        );
1614        assert!(
1615            restrictions.access_boundary_rules[0]
1616                .compiled_availability_condition
1617                .is_none()
1618        );
1619        assert_eq!(
1620            restrictions.access_boundary_rules[1].available_resource,
1621            "//storage.googleapis.com/projects/_/buckets/second-bucket"
1622        );
1623        assert!(
1624            restrictions.access_boundary_rules[1]
1625                .compiled_availability_condition
1626                .is_some()
1627        );
1628    }
1629
1630    #[tokio::test]
1631    async fn rejects_invalid_grant_before_io() {
1632        let now = timestamp("2030-01-01T00:00:00Z");
1633        let http = MockHttpSend::new([]);
1634        let operation = ClientSideCredentialAccessBoundaryGranter::new(
1635            CredentialAccessBoundaryGrant::for_object_prefix(
1636                "example-bucket",
1637                "",
1638                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1639            ),
1640        )
1641        .with_time(now);
1642        let err = operation
1643            .grant_credential(
1644                &Context::new().with_http_send(http.clone()),
1645                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1646                None,
1647            )
1648            .await
1649            .expect_err("invalid grant must fail");
1650
1651        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1652        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1653    }
1654
1655    #[tokio::test]
1656    async fn rejects_incompatible_sources_and_lifetimes_before_io() {
1657        let now = timestamp("2030-01-01T00:00:00Z");
1658        let http = MockHttpSend::new([]);
1659        let ctx = Context::new().with_http_send(http.clone());
1660        let operation =
1661            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
1662        let valid_token = Token {
1663            access_token: "source".to_string(),
1664            expires_at: Some(now + Duration::from_secs(2 * 60 * 60)),
1665        };
1666        let invalid_sources = [
1667            Credential::with_service_account(ServiceAccount {
1668                private_key: "private".to_string(),
1669                client_email: "service@example.com".to_string(),
1670            }),
1671            Credential {
1672                service_account: Some(ServiceAccount {
1673                    private_key: "private".to_string(),
1674                    client_email: "service@example.com".to_string(),
1675                }),
1676                token: Some(valid_token),
1677                signer_email: None,
1678            },
1679            source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
1680            source_token("source", None),
1681            source_token("source", Some(now + DEFAULT_MINIMUM_TOKEN_LIFETIME)),
1682        ];
1683        for source in invalid_sources {
1684            let err = operation
1685                .grant_credential(&ctx, &source, None)
1686                .await
1687                .expect_err("incompatible source must fail");
1688            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1689        }
1690
1691        let valid_source = source_token("source", Some(now + Duration::from_secs(13 * 60 * 60)));
1692        for lifetime in [
1693            Duration::ZERO,
1694            MAX_ACCESS_TOKEN_LIFETIME,
1695            MAX_ACCESS_TOKEN_LIFETIME + Duration::from_secs(1),
1696        ] {
1697            let err = operation
1698                .grant_credential(&ctx, &valid_source, Some(lifetime))
1699                .await
1700                .expect_err("invalid minimum lifetime must fail");
1701            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1702        }
1703        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1704    }
1705
1706    #[test]
1707    fn validates_exact_minimum_lifetime_boundary_after_rounding() {
1708        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant());
1709        assert_eq!(
1710            operation
1711                .effective_minimum_lifetime(Some(MAX_MINIMUM_TOKEN_LIFETIME))
1712                .expect("the largest feasible whole-second lifetime must be accepted"),
1713            MAX_MINIMUM_TOKEN_LIFETIME
1714        );
1715
1716        for lifetime in [
1717            MAX_MINIMUM_TOKEN_LIFETIME + Duration::from_nanos(1),
1718            MAX_ACCESS_TOKEN_LIFETIME,
1719            Duration::MAX,
1720        ] {
1721            let err = operation
1722                .effective_minimum_lifetime(Some(lifetime))
1723                .expect_err("a lifetime that rounds to twelve hours must fail");
1724            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1725        }
1726    }
1727
1728    #[tokio::test]
1729    async fn accepts_maximum_intermediary_and_minimum_lifetime_boundaries() {
1730        let request_time = timestamp("2030-01-01T00:00:00Z");
1731        let response_time = timestamp("2030-01-01T00:00:01Z");
1732        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1733            .with_time(request_time)
1734            .with_time_after_request(response_time)
1735            .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1736        let output = operation
1737            .grant_credential(
1738                &Context::new().with_http_send(MockHttpSend::new([success_response(
1739                    "intermediary",
1740                    Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
1741                )])),
1742                &source_token(
1743                    "source",
1744                    Some(request_time + Duration::from_secs(13 * 60 * 60)),
1745                ),
1746                Some(MAX_MINIMUM_TOKEN_LIFETIME),
1747            )
1748            .await
1749            .expect("maximum feasible lifetime must succeed");
1750
1751        assert_eq!(
1752            output_token(&output).expires_at,
1753            Some(timestamp("2030-01-01T12:00:01Z"))
1754        );
1755    }
1756
1757    #[tokio::test]
1758    async fn anchors_clamps_and_requires_intermediary_expiration() {
1759        let request_time = timestamp("2030-01-01T00:00:00Z");
1760        let response_time = timestamp("2030-01-01T00:00:02Z");
1761        let source_expiry = timestamp("2030-01-01T02:00:00Z");
1762        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1763            .with_time(request_time)
1764            .with_time_after_request(response_time)
1765            .with_nonces([
1766                [1; AES_GCM_NONCE_BYTES],
1767                [2; AES_GCM_NONCE_BYTES],
1768                [3; AES_GCM_NONCE_BYTES],
1769            ]);
1770        let source = source_token("source", Some(source_expiry));
1771
1772        let explicit = operation
1773            .grant_credential(
1774                &Context::new().with_http_send(MockHttpSend::new([success_response(
1775                    "explicit",
1776                    Some(3600),
1777                )])),
1778                &source,
1779                None,
1780            )
1781            .await
1782            .expect("explicit expiration must succeed");
1783        assert_eq!(
1784            output_token(&explicit).expires_at,
1785            Some(timestamp("2030-01-01T01:00:02Z"))
1786        );
1787
1788        let missing_expiration_operation =
1789            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1790                .with_time(request_time)
1791                .with_time_after_request(response_time);
1792        let err = missing_expiration_operation
1793            .grant_credential(
1794                &Context::new()
1795                    .with_http_send(MockHttpSend::new([success_response("inherited", None)])),
1796                &source,
1797                None,
1798            )
1799            .await
1800            .expect_err("client-issued CAB requires a service-account STS expiration");
1801        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1802
1803        let clamped_operation =
1804            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1805                .with_time(request_time)
1806                .with_time_after_request(response_time)
1807                .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
1808        let clamped = clamped_operation
1809            .grant_credential(
1810                &Context::new().with_http_send(MockHttpSend::new([success_response(
1811                    "clamped",
1812                    Some(3 * 60 * 60),
1813                )])),
1814                &source,
1815                None,
1816            )
1817            .await
1818            .expect("STS expiration must clamp to source expiration");
1819        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
1820    }
1821
1822    #[tokio::test]
1823    async fn rejects_malformed_expiration_and_sts_responses() {
1824        let now = timestamp("2030-01-01T00:00:00Z");
1825        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1826        let malformed = [
1827            success_response("zero", Some(0)),
1828            success_response("too-long", Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1)),
1829            response(
1830                http::StatusCode::OK,
1831                format!(
1832                    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}"}}"#
1833                ),
1834            ),
1835            response(
1836                http::StatusCode::OK,
1837                br#"{"access_token":"missing-fields"}"#.as_slice(),
1838            ),
1839            success_response_with_key("bad-key", "not base64!", Some(3600)),
1840        ];
1841
1842        for response in malformed {
1843            let http = MockHttpSend::new([response]);
1844            let err = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1845                .with_time(now)
1846                .grant_credential(&Context::new().with_http_send(http), &source, None)
1847                .await
1848                .expect_err("malformed response must fail");
1849            assert_eq!(err.kind(), ErrorKind::Unexpected);
1850            let debug = format!("{err:?}");
1851            assert!(!debug.contains("bad-key"));
1852            assert!(!debug.contains(GOOGLE_AUTH_LIBRARY_SESSION_KEY));
1853        }
1854    }
1855
1856    #[tokio::test]
1857    async fn validates_tink_keyset_shape_and_algorithm() {
1858        let now = timestamp("2030-01-01T00:00:00Z");
1859        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1860        let keys = [
1861            encoded_tink_keyset(
1862                7,
1863                0,
1864                TINK_PREFIX,
1865                AES_GCM_KEY_TYPE_URL,
1866                TINK_KEY_MATERIAL_SYMMETRIC,
1867                0,
1868                vec![1; 32],
1869            ),
1870            encoded_tink_keyset(
1871                7,
1872                TINK_KEY_STATUS_ENABLED,
1873                0,
1874                AES_GCM_KEY_TYPE_URL,
1875                TINK_KEY_MATERIAL_SYMMETRIC,
1876                0,
1877                vec![1; 32],
1878            ),
1879            encoded_tink_keyset(
1880                7,
1881                TINK_KEY_STATUS_ENABLED,
1882                TINK_PREFIX,
1883                "type.googleapis.com/google.crypto.tink.ChaCha20Poly1305Key",
1884                TINK_KEY_MATERIAL_SYMMETRIC,
1885                0,
1886                vec![1; 32],
1887            ),
1888            encoded_tink_keyset(
1889                7,
1890                TINK_KEY_STATUS_ENABLED,
1891                TINK_PREFIX,
1892                AES_GCM_KEY_TYPE_URL,
1893                0,
1894                0,
1895                vec![1; 32],
1896            ),
1897            encoded_tink_keyset(
1898                7,
1899                TINK_KEY_STATUS_ENABLED,
1900                TINK_PREFIX,
1901                AES_GCM_KEY_TYPE_URL,
1902                TINK_KEY_MATERIAL_SYMMETRIC,
1903                1,
1904                vec![1; 32],
1905            ),
1906            encoded_tink_keyset(
1907                7,
1908                TINK_KEY_STATUS_ENABLED,
1909                TINK_PREFIX,
1910                AES_GCM_KEY_TYPE_URL,
1911                TINK_KEY_MATERIAL_SYMMETRIC,
1912                0,
1913                vec![1; 24],
1914            ),
1915        ];
1916
1917        for key in keys {
1918            let err =
1919                ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1920                    .with_time(now)
1921                    .grant_credential(
1922                        &Context::new().with_http_send(MockHttpSend::new([
1923                            success_response_with_key("intermediary", &key, Some(3600)),
1924                        ])),
1925                        &source,
1926                        None,
1927                    )
1928                    .await
1929                    .expect_err("invalid keyset must fail");
1930            assert_eq!(err.kind(), ErrorKind::Unexpected);
1931            assert!(!format!("{err:?}").contains(&key));
1932        }
1933    }
1934
1935    #[tokio::test]
1936    async fn checks_post_io_and_post_generation_validity() {
1937        let now = timestamp("2030-01-01T00:00:00Z");
1938        let source_expiry = now + Duration::from_secs(60 * 60);
1939
1940        let post_io = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1941            .with_time(now)
1942            .with_time_after_request(now + Duration::from_secs(31 * 60));
1943        let err = post_io
1944            .grant_credential(
1945                &Context::new().with_http_send(MockHttpSend::new([success_response(
1946                    "intermediary",
1947                    Some(3600),
1948                )])),
1949                &source_token("source", Some(source_expiry)),
1950                None,
1951            )
1952            .await
1953            .expect_err("intermediary must cover the minimum after I/O");
1954        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1955
1956        let post_generation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1957            .with_time(now)
1958            .with_time_after_generation(now + Duration::from_secs(30 * 60))
1959            .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1960        let err = post_generation
1961            .grant_credential(
1962                &Context::new().with_http_send(MockHttpSend::new([success_response(
1963                    "intermediary",
1964                    Some(60 * 60),
1965                )])),
1966                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1967                None,
1968            )
1969            .await
1970            .expect_err("output must cover the minimum after local generation");
1971        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1972    }
1973
1974    #[tokio::test]
1975    async fn reuses_intermediary_but_never_caches_outputs() {
1976        let now = timestamp("2030-01-01T00:00:00Z");
1977        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1978        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1979            .with_time(now)
1980            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
1981        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1982        let ctx = Context::new().with_http_send(http.clone());
1983
1984        let first = operation
1985            .grant_credential(&ctx, &source, None)
1986            .await
1987            .expect("first grant must succeed");
1988        let second = operation
1989            .grant_credential(&ctx, &source, None)
1990            .await
1991            .expect("second grant must succeed");
1992
1993        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
1994        assert_eq!(operation.cache_len().await, 1);
1995        assert_ne!(
1996            output_token(&first).access_token,
1997            output_token(&second).access_token
1998        );
1999        assert_eq!(
2000            output_token(&first).expires_at,
2001            output_token(&second).expires_at
2002        );
2003        assert_eq!(decrypt_restrictions(&first).1, [1; AES_GCM_NONCE_BYTES]);
2004        assert_eq!(decrypt_restrictions(&second).1, [2; AES_GCM_NONCE_BYTES]);
2005    }
2006
2007    #[tokio::test]
2008    async fn refreshes_only_when_cached_intermediary_cannot_cover_minimum() {
2009        let now = timestamp("2030-01-01T00:00:00Z");
2010        let http = MockHttpSend::new([
2011            success_response("intermediary-1", Some(3600)),
2012            success_response("intermediary-2", Some(3600)),
2013        ]);
2014        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2015            .with_time(now)
2016            .with_nonces([
2017                [1; AES_GCM_NONCE_BYTES],
2018                [2; AES_GCM_NONCE_BYTES],
2019                [3; AES_GCM_NONCE_BYTES],
2020            ]);
2021        let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2022        let ctx = Context::new().with_http_send(http.clone());
2023
2024        let first = operation
2025            .grant_credential(&ctx, &source, None)
2026            .await
2027            .expect("initial grant must succeed");
2028        let still_fresh = operation
2029            .clone()
2030            .with_time(now + Duration::from_secs(20 * 60))
2031            .grant_credential(&ctx, &source, None)
2032            .await
2033            .expect("fresh intermediary must be reused");
2034        let refreshed = operation
2035            .clone()
2036            .with_time(now + Duration::from_secs(31 * 60))
2037            .grant_credential(&ctx, &source, None)
2038            .await
2039            .expect("near-expiry intermediary must refresh");
2040
2041        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2042        assert_eq!(decrypt_restrictions(&first).0, "intermediary-1");
2043        assert_eq!(decrypt_restrictions(&still_fresh).0, "intermediary-1");
2044        assert_eq!(decrypt_restrictions(&refreshed).0, "intermediary-2");
2045    }
2046
2047    #[tokio::test]
2048    async fn failed_refresh_never_returns_insufficient_cached_output() {
2049        let now = timestamp("2030-01-01T00:00:00Z");
2050        let http = MockHttpSend::new([
2051            success_response("intermediary-1", Some(3600)),
2052            response(
2053                http::StatusCode::SERVICE_UNAVAILABLE,
2054                r#"{"error":"backend_error","error_description":"do not return stale material"}"#,
2055            ),
2056        ]);
2057        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2058            .with_time(now)
2059            .with_nonces([[1; AES_GCM_NONCE_BYTES]]);
2060        let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2061        let ctx = Context::new().with_http_send(http.clone());
2062        operation
2063            .grant_credential(&ctx, &source, None)
2064            .await
2065            .expect("initial grant must succeed");
2066
2067        let err = operation
2068            .clone()
2069            .with_time(now + Duration::from_secs(31 * 60))
2070            .grant_credential(&ctx, &source, None)
2071            .await
2072            .expect_err("failed refresh must not return cached output");
2073        assert_eq!(err.kind(), ErrorKind::Unexpected);
2074        assert!(err.is_retryable());
2075        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2076        assert!(!format!("{err:?}").contains("do not return stale material"));
2077    }
2078
2079    #[tokio::test]
2080    async fn partitions_and_bounds_intermediary_cache() {
2081        let now = timestamp("2030-01-01T00:00:00Z");
2082        let responses = (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2083            .map(|index| success_response(&format!("intermediary-{index}"), Some(3600)))
2084            .collect::<Vec<_>>();
2085        let http = MockHttpSend::new(responses);
2086        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2087            .with_time(now)
2088            .with_nonces(
2089                (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2090                    .map(|index| [index as u8; AES_GCM_NONCE_BYTES]),
2091            );
2092        let ctx = Context::new().with_http_send(http.clone());
2093        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
2094
2095        for index in 0..=INTERMEDIARY_CACHE_CAPACITY {
2096            operation
2097                .grant_credential(
2098                    &ctx,
2099                    &source_token(&format!("source-{index}"), Some(source_expiry)),
2100                    None,
2101                )
2102                .await
2103                .expect("partitioned grant must succeed");
2104        }
2105        assert_eq!(
2106            http.calls.load(Ordering::SeqCst),
2107            INTERMEDIARY_CACHE_CAPACITY + 1
2108        );
2109        assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2110
2111        operation
2112            .grant_credential(&ctx, &source_token("source-0", Some(source_expiry)), None)
2113            .await
2114            .expect("oldest authority must be fetched after eviction");
2115        assert_eq!(
2116            http.calls.load(Ordering::SeqCst),
2117            INTERMEDIARY_CACHE_CAPACITY + 2
2118        );
2119        assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2120
2121        let a = IntermediaryCacheKey {
2122            endpoint: STS_ENDPOINT,
2123            source_authority: "authority".to_string(),
2124            source_expires_at: source_expiry,
2125        };
2126        let b = IntermediaryCacheKey {
2127            endpoint: "https://sts.example.invalid/v1/token",
2128            source_authority: "authority".to_string(),
2129            source_expires_at: source_expiry,
2130        };
2131        assert!(a != b, "endpoint identity must partition cache keys");
2132    }
2133
2134    #[tokio::test]
2135    async fn source_authority_partition_reuses_each_matching_entry() {
2136        let now = timestamp("2030-01-01T00:00:00Z");
2137        let http = MockHttpSend::new([
2138            success_response("intermediary-a", Some(3600)),
2139            success_response("intermediary-b", Some(3600)),
2140        ]);
2141        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2142            .with_time(now)
2143            .with_nonces([
2144                [1; AES_GCM_NONCE_BYTES],
2145                [2; AES_GCM_NONCE_BYTES],
2146                [3; AES_GCM_NONCE_BYTES],
2147            ]);
2148        let ctx = Context::new().with_http_send(http.clone());
2149        let expiry = now + Duration::from_secs(2 * 60 * 60);
2150        let source_a = source_token("source-a", Some(expiry));
2151        let source_b = source_token("source-b", Some(expiry));
2152
2153        let first_a = operation
2154            .grant_credential(&ctx, &source_a, None)
2155            .await
2156            .expect("source A must succeed");
2157        let output_b = operation
2158            .grant_credential(&ctx, &source_b, None)
2159            .await
2160            .expect("source B must succeed");
2161        let second_a = operation
2162            .grant_credential(&ctx, &source_a, None)
2163            .await
2164            .expect("source A cache entry must be reused");
2165
2166        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2167        assert_eq!(operation.cache_len().await, 2);
2168        assert_eq!(decrypt_restrictions(&first_a).0, "intermediary-a");
2169        assert_eq!(decrypt_restrictions(&output_b).0, "intermediary-b");
2170        assert_eq!(decrypt_restrictions(&second_a).0, "intermediary-a");
2171    }
2172
2173    #[tokio::test]
2174    async fn unrelated_source_partitions_refresh_independently() {
2175        let now = timestamp("2030-01-01T00:00:00Z");
2176        let (http, gate) = MockHttpSend::gated([
2177            success_response("intermediary-a", Some(3600)),
2178            success_response("intermediary-b", Some(3600)),
2179        ]);
2180        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2181            .with_time(now)
2182            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2183        let ctx = Context::new().with_http_send(http.clone());
2184        let expiry = now + Duration::from_secs(2 * 60 * 60);
2185
2186        let first = tokio::spawn({
2187            let operation = operation.clone();
2188            let ctx = ctx.clone();
2189            async move {
2190                operation
2191                    .grant_credential(&ctx, &source_token("source-a", Some(expiry)), None)
2192                    .await
2193            }
2194        });
2195        gate.wait_started().await;
2196
2197        let second = tokio::spawn({
2198            let operation = operation.clone();
2199            let ctx = ctx.clone();
2200            async move {
2201                operation
2202                    .grant_credential(&ctx, &source_token("source-b", Some(expiry)), None)
2203                    .await
2204            }
2205        });
2206        let second_started =
2207            tokio::time::timeout(Duration::from_secs(1), gate.wait_started()).await;
2208        gate.release_one();
2209        gate.release_one();
2210        assert!(
2211            second_started.is_ok(),
2212            "an unrelated source partition must not wait for the first STS exchange"
2213        );
2214
2215        first
2216            .await
2217            .expect("first grant task must not panic")
2218            .expect("first source partition must succeed");
2219        second
2220            .await
2221            .expect("second grant task must not panic")
2222            .expect("second source partition must succeed");
2223        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2224        assert_eq!(operation.cache_len().await, 2);
2225        assert_eq!(operation.refresh_lock_len(), 0);
2226    }
2227
2228    #[tokio::test]
2229    async fn concurrent_grants_share_successful_intermediary_refresh() {
2230        let now = timestamp("2030-01-01T00:00:00Z");
2231        let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2232        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2233            .with_time(now)
2234            .with_nonces((0..8).map(|index| [index; AES_GCM_NONCE_BYTES]));
2235        let ctx = Context::new().with_http_send(http.clone());
2236        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2237        let mut tasks = Vec::new();
2238        for _ in 0..8 {
2239            let operation = operation.clone();
2240            let ctx = ctx.clone();
2241            let source = source.clone();
2242            tasks.push(tokio::spawn(async move {
2243                operation.grant_credential(&ctx, &source, None).await
2244            }));
2245        }
2246
2247        gate.wait_started().await;
2248        for _ in 0..4 {
2249            tokio::task::yield_now().await;
2250        }
2251        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2252        gate.release_one();
2253
2254        let mut tokens = HashSet::new();
2255        for task in tasks {
2256            let output = task
2257                .await
2258                .expect("grant task must not panic")
2259                .expect("concurrent grant must succeed");
2260            tokens.insert(output_token(&output).access_token.clone());
2261        }
2262        assert_eq!(tokens.len(), 8);
2263        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2264        assert_eq!(operation.cache_len().await, 1);
2265        assert_eq!(operation.refresh_lock_len(), 0);
2266    }
2267
2268    #[tokio::test]
2269    async fn waiter_retries_after_serialized_refresh_failure() {
2270        let now = timestamp("2030-01-01T00:00:00Z");
2271        let (http, gate) = MockHttpSend::gated([
2272            response(
2273                http::StatusCode::SERVICE_UNAVAILABLE,
2274                r#"{"error":"backend_error"}"#,
2275            ),
2276            success_response("intermediary", Some(3600)),
2277        ]);
2278        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2279            .with_time(now)
2280            .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
2281        let ctx = Context::new().with_http_send(http.clone());
2282        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2283
2284        let leader = tokio::spawn({
2285            let operation = operation.clone();
2286            let ctx = ctx.clone();
2287            let source = source.clone();
2288            async move { operation.grant_credential(&ctx, &source, None).await }
2289        });
2290        gate.wait_started().await;
2291        let waiter = tokio::spawn({
2292            let operation = operation.clone();
2293            let ctx = ctx.clone();
2294            async move { operation.grant_credential(&ctx, &source, None).await }
2295        });
2296        for _ in 0..4 {
2297            tokio::task::yield_now().await;
2298        }
2299        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2300
2301        gate.release_one();
2302        let err = leader
2303            .await
2304            .expect("leader task must not panic")
2305            .expect_err("leader refresh must fail");
2306        assert_eq!(err.kind(), ErrorKind::Unexpected);
2307        assert!(err.is_retryable());
2308
2309        gate.wait_started().await;
2310        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2311        gate.release_one();
2312        let output = waiter
2313            .await
2314            .expect("waiter task must not panic")
2315            .expect("waiter must retry and succeed");
2316        assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2317        assert_eq!(operation.cache_len().await, 1);
2318        assert_eq!(operation.refresh_lock_len(), 0);
2319    }
2320
2321    #[tokio::test]
2322    async fn cancelled_refresh_releases_partition_lock_without_caching_partial_state() {
2323        let now = timestamp("2030-01-01T00:00:00Z");
2324        let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2325        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2326            .with_time(now)
2327            .with_nonces([[5; AES_GCM_NONCE_BYTES]]);
2328        let ctx = Context::new().with_http_send(http.clone());
2329        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2330
2331        let cancelled = {
2332            let operation = operation.clone();
2333            let ctx = ctx.clone();
2334            let source = source.clone();
2335            tokio::spawn(async move { operation.grant_credential(&ctx, &source, None).await })
2336        };
2337        gate.wait_started().await;
2338        cancelled.abort();
2339        assert!(
2340            cancelled
2341                .await
2342                .expect_err("task must be cancelled")
2343                .is_cancelled()
2344        );
2345        assert_eq!(operation.cache_len().await, 0);
2346        assert_eq!(operation.refresh_lock_len(), 0);
2347
2348        gate.release_one();
2349        let output = tokio::time::timeout(
2350            Duration::from_secs(2),
2351            operation.grant_credential(&ctx, &source, None),
2352        )
2353        .await
2354        .expect("retry must not deadlock")
2355        .expect("retry after cancellation must succeed");
2356        assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2357        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2358        assert_eq!(operation.cache_len().await, 1);
2359        assert_eq!(operation.refresh_lock_len(), 0);
2360    }
2361
2362    #[tokio::test]
2363    async fn with_grant_shares_intermediary_for_distinct_authorization() {
2364        let now = timestamp("2030-01-01T00:00:00Z");
2365        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2366        let bucket = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2367            .with_time(now)
2368            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2369        let prefix = bucket
2370            .clone()
2371            .with_grant(CredentialAccessBoundaryGrant::for_object_prefix(
2372                "example-bucket",
2373                "tenant/",
2374                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
2375            ));
2376        let ctx = Context::new().with_http_send(http.clone());
2377        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2378
2379        let bucket_output = bucket
2380            .grant_credential(&ctx, &source, None)
2381            .await
2382            .expect("bucket grant must succeed");
2383        let prefix_output = prefix
2384            .grant_credential(&ctx, &source, None)
2385            .await
2386            .expect("prefix grant must succeed");
2387        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2388        assert!(
2389            decrypt_restrictions(&bucket_output).2.access_boundary_rules[0]
2390                .compiled_availability_condition
2391                .is_none()
2392        );
2393        assert!(
2394            decrypt_restrictions(&prefix_output).2.access_boundary_rules[0]
2395                .compiled_availability_condition
2396                .is_some()
2397        );
2398    }
2399
2400    #[tokio::test]
2401    async fn granter_lifecycle_caches_source_and_intermediary_but_not_outputs() {
2402        let now = Timestamp::now();
2403        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2404        let (provider, provider_calls) = FixedCredentialProvider::new(source);
2405        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2406        let granter = Granter::new(
2407            Context::new().with_http_send(http.clone()),
2408            provider,
2409            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2410                .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]),
2411        );
2412
2413        let first = granter.grant(None).await.expect("first grant must succeed");
2414        let second = granter
2415            .grant(None)
2416            .await
2417            .expect("second grant must succeed");
2418        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
2419        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2420        assert_ne!(
2421            output_token(&first).access_token,
2422            output_token(&second).access_token
2423        );
2424    }
2425
2426    #[tokio::test]
2427    async fn generated_token_is_consumed_by_existing_google_signer() {
2428        let now = Timestamp::now();
2429        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2430            .with_nonces([[8; AES_GCM_NONCE_BYTES]]);
2431        let output = operation
2432            .grant_credential(
2433                &Context::new().with_http_send(MockHttpSend::new([success_response(
2434                    "intermediary",
2435                    Some(3600),
2436                )])),
2437                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
2438                None,
2439            )
2440            .await
2441            .expect("client-side grant must succeed");
2442        let expected = format!("Bearer {}", output_token(&output).access_token);
2443        let (provider, _) = FixedCredentialProvider::new(output);
2444        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
2445        let mut parts =
2446            http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
2447                .body(())
2448                .expect("request must build")
2449                .into_parts()
2450                .0;
2451
2452        signer
2453            .sign(&mut parts, None)
2454            .await
2455            .expect("existing signer must consume client-issued CAB token");
2456        assert_eq!(parts.headers[AUTHORIZATION], expected);
2457        assert!(parts.headers[AUTHORIZATION].is_sensitive());
2458    }
2459
2460    #[tokio::test]
2461    async fn sts_and_transport_errors_are_semantic_and_redacted() {
2462        let now = timestamp("2030-01-01T00:00:00Z");
2463        let source = source_token(
2464            "source-secret",
2465            Some(now + Duration::from_secs(2 * 60 * 60)),
2466        );
2467        let operation =
2468            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
2469        let err = operation
2470            .grant_credential(
2471                &Context::new().with_http_send(MockHttpSend::new([response(
2472                    http::StatusCode::BAD_REQUEST,
2473                    r#"{"error":"invalid_grant","error_description":"source-secret raw-response-secret"}"#,
2474                )])),
2475                &source,
2476                None,
2477            )
2478            .await
2479            .expect_err("STS error must fail");
2480        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
2481        assert!(format!("{err:?}").contains("invalid_grant"));
2482        assert!(!format!("{err:?}").contains("source-secret"));
2483        assert!(!format!("{err:?}").contains("raw-response-secret"));
2484
2485        let err = operation
2486            .grant_credential(
2487                &Context::new().with_http_send(SecretTransportError),
2488                &source,
2489                None,
2490            )
2491            .await
2492            .expect_err("transport error must fail");
2493        assert_eq!(err.kind(), ErrorKind::Unexpected);
2494        assert!(err.is_retryable());
2495        assert!(!format!("{err:?}").contains("source-secret"));
2496        assert!(!format!("{err:?}").contains("transport retained"));
2497    }
2498
2499    #[tokio::test]
2500    async fn debug_redacts_grant_request_and_all_credential_material() {
2501        let now = timestamp("2030-01-01T00:00:00Z");
2502        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
2503            "sensitive-bucket",
2504            "sensitive/prefix",
2505            CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
2506        );
2507        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant.clone())
2508            .with_time(now)
2509            .with_nonces([[6; AES_GCM_NONCE_BYTES]]);
2510        let source = source_token(
2511            "source-secret",
2512            Some(now + Duration::from_secs(2 * 60 * 60)),
2513        );
2514        let http = MockHttpSend::new([success_response("intermediary-secret", Some(3600))]);
2515        let output = operation
2516            .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
2517            .await
2518            .expect("client-side grant must succeed");
2519        let request = http
2520            .requests()
2521            .into_iter()
2522            .next()
2523            .expect("request must be captured");
2524        let key =
2525            TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY).expect("official key must parse");
2526
2527        for (debug, secret) in [
2528            (format!("{grant:?}"), "sensitive-bucket"),
2529            (format!("{grant:?}"), "sensitive/prefix"),
2530            (format!("{operation:?}"), "sensitive-bucket"),
2531            (format!("{source:?}"), "source-secret"),
2532            (format!("{request:?}"), "source-secret"),
2533            (format!("{output:?}"), "intermediary-secret"),
2534            (format!("{key:?}"), "cc7c"),
2535            (
2536                format!("{:?}", CredentialAccessBoundaryPermissions::OBJECT_ADMIN),
2537                "storage.objectAdmin",
2538            ),
2539        ] {
2540            assert!(!debug.contains(secret), "{debug}");
2541        }
2542        assert_eq!(
2543            format!("{operation:?}"),
2544            "ClientSideCredentialAccessBoundaryGranter { .. }"
2545        );
2546    }
2547}