You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/07/31 06:58:17 UTC

(opendal-reqsign) branch main updated: feat: add client-side GCP Credential Access Boundary (#808)

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git


The following commit(s) were added to refs/heads/main by this push:
     new cd22149  feat: add client-side GCP Credential Access Boundary (#808)
cd22149 is described below

commit cd22149ee361bad49aab60ec651799e3d80811f4
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Fri Jul 31 14:58:13 2026 +0800

    feat: add client-side GCP Credential Access Boundary (#808)
    
    Closes #804.
    
    Google's server-side Credential Access Boundary flow exchanges a source
    OAuth access token through STS for every granted credential. Workloads
    that issue many differently scoped tokens need Google's client-side flow
    so they can reuse intermediary material without caching granted outputs.
    
    This PR adds an explicitly selected, feature-gated client-side granter
    alongside an explicitly named server-side granter. Both remain
    service-specific implementations of `GrantCredential<Credential =
    reqsign_google::Credential>`, preserving the existing source-cache and
    Google signing architecture. The client keeps only bounded intermediary
    material and generates a fresh output for every grant; there is no
    implicit fallback between modes.
    
    The implementation follows Google's intermediary-token and session-key
    protocol, validates typed Cloud Storage boundaries, and fails closed if
    STS returns an unsupported Tink AEAD key. The opt-in GCP
    interoperability test still requires a service-account access token and
    bucket fixture, so live acceptance is not part of the local validation.
---
 .github/workflows/google.yml                       |    4 +-
 Cargo.toml                                         |    2 +
 core/src/context.rs                                |    4 +
 reqsign/Cargo.toml                                 |    5 +
 reqsign/README.md                                  |    1 +
 reqsign/src/google.rs                              |   19 +-
 services/google/Cargo.toml                         |   18 +
 services/google/src/credential_access_boundary.rs  | 1344 +---------
 .../src/credential_access_boundary/client_side.rs  | 2554 ++++++++++++++++++++
 .../src/credential_access_boundary/server_side.rs  |  969 ++++++++
 .../google/src/credential_access_boundary/sts.rs   |  125 +
 services/google/src/lib.rs                         |   10 +-
 services/google/tests/README.md                    |   11 +
 .../google/tests/credential_access_boundary.rs     |  148 ++
 14 files changed, 3950 insertions(+), 1264 deletions(-)

diff --git a/.github/workflows/google.yml b/.github/workflows/google.yml
index c612b19..383d998 100644
--- a/.github/workflows/google.yml
+++ b/.github/workflows/google.yml
@@ -47,8 +47,8 @@ jobs:
         working-directory: ./services/google
         run: |
           echo "::group::Running unit tests"
-          cargo test --lib --no-fail-fast
-          cargo test --doc --no-fail-fast
+          cargo test --lib --all-features --no-fail-fast
+          cargo test --doc --all-features --no-fail-fast
           echo "::endgroup::"
 
   # Check if we can run integration tests
diff --git a/Cargo.toml b/Cargo.toml
index aca9d4c..def0fa4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,6 +43,7 @@ reqsign-tencent-cos = { version = "3.0.4", path = "services/tencent-cos" }
 reqsign-volcengine-tos = { version = "3.1.0", path = "services/volcengine-tos" }
 
 # Crates.io dependencies
+aes-gcm = "0.11"
 anyhow = "1"
 base64 = { version = "0.23", default-features = false, features = ["std"] }
 bytes = "1"
@@ -60,6 +61,7 @@ pem = "3.0"
 percent-encoding = "2"
 p256 = { version = "0.14.0", default-features = false, features = ["ecdsa"] }
 pretty_assertions = "1.3"
+prost = "0.14"
 quick-xml = { version = "0.41.0", features = ["serialize"] }
 reqwest = { version = "0.13.1", default-features = false }
 rsa = { version = "0.9.2", features = ["pkcs5", "sha2"] }
diff --git a/core/src/context.rs b/core/src/context.rs
index 1a55b90..2e14995 100644
--- a/core/src/context.rs
+++ b/core/src/context.rs
@@ -216,6 +216,10 @@ impl<T: FileReadDyn + ?Sized> FileRead for Arc<T> {
 ///
 /// For example, fetch IMDS token from AWS or OAuth2 refresh token. This trait is designed
 /// especially for the signer, please don't use it as a general http client.
+///
+/// Requests and responses can contain credentials in headers or bodies.
+/// Implementations must not log or include their raw contents in `Debug` output or returned
+/// errors.
 pub trait HttpSend: Debug + Send + Sync + 'static {
     /// Send http request and return the response.
     fn http_send(
diff --git a/reqsign/Cargo.toml b/reqsign/Cargo.toml
index 96a414d..e4f303a 100644
--- a/reqsign/Cargo.toml
+++ b/reqsign/Cargo.toml
@@ -67,6 +67,10 @@ aws-v4 = ["dep:reqsign-aws-v4"]
 aws-v4a = ["dep:reqsign-aws-v4a"]
 azure = ["dep:reqsign-azure-storage"]
 google = ["dep:reqsign-google"]
+google-credential-access-boundary-client-side = [
+  "google",
+  "reqsign-google/credential-access-boundary-client-side",
+]
 huaweicloud = ["dep:reqsign-huaweicloud-obs"]
 oracle = ["dep:reqsign-oracle"]
 tencent = ["dep:reqsign-tencent-cos"]
@@ -79,6 +83,7 @@ full = [
   "aws-v4a",
   "azure",
   "google",
+  "google-credential-access-boundary-client-side",
   "huaweicloud",
   "oracle",
   "tencent",
diff --git a/reqsign/README.md b/reqsign/README.md
index 80d9823..a3d643c 100644
--- a/reqsign/README.md
+++ b/reqsign/README.md
@@ -182,6 +182,7 @@ let signer = oracle::default_signer();
 - `aws`: Enable AWS services support
 - `azure`: Enable Azure Storage support
 - `google`: Enable Google Cloud support
+- `google-credential-access-boundary-client-side`: Enable client-side Google Credential Access Boundary token generation; implies `google` (server-side CAB is included by `google`)
 - `huaweicloud`: Enable Huawei Cloud OBS support
 - `oracle`: Enable Oracle Cloud support
 - `tencent`: Enable Tencent COS support
diff --git a/reqsign/src/google.rs b/reqsign/src/google.rs
index 2fe7b47..8fa4670 100644
--- a/reqsign/src/google.rs
+++ b/reqsign/src/google.rs
@@ -22,15 +22,25 @@
 //!
 //! ## Credential Access Boundary downscoping
 //!
-//! A control plane can exchange a source OAuth access token for a token restricted
-//! to selected Cloud Storage buckets or object prefixes:
+//! [`ServerSideCredentialAccessBoundaryGranter`] performs an STS exchange for
+//! every output and is available with the `google` feature. Enable
+//! `google-credential-access-boundary-client-side` to use
+//! `ClientSideCredentialAccessBoundaryGranter`, which caches intermediary
+//! material and generates each downscoped token locally. Callers choose the
+//! mode explicitly; there is no implicit fallback between them.
+//!
+//! A control plane can obtain reusable intermediary material from Google STS and
+//! locally generate distinct tokens restricted to selected Cloud Storage buckets
+//! or object prefixes:
 //!
 //! ```no_run
+//! # #[cfg(feature = "google-credential-access-boundary-client-side")]
+//! # mod credential_access_boundary_example {
 //! use std::time::Duration;
 //!
 //! use reqsign::{Context, Granter, time::Timestamp};
 //! use reqsign::google::{
-//!     CredentialAccessBoundaryGrant, CredentialAccessBoundaryGranter,
+//!     ClientSideCredentialAccessBoundaryGranter, CredentialAccessBoundaryGrant,
 //!     CredentialAccessBoundaryPermissions, TokenCredentialProvider,
 //! };
 //!
@@ -48,13 +58,14 @@
 //! let downscoped = Granter::new(
 //!     context,
 //!     source,
-//!     CredentialAccessBoundaryGranter::new(grant),
+//!     ClientSideCredentialAccessBoundaryGranter::new(grant),
 //! )
 //! .grant(None)
 //! .await?;
 //! # let _ = downscoped;
 //! # Ok(())
 //! # }
+//! # }
 //! ```
 
 // Re-export all Google Cloud signing types
diff --git a/services/google/Cargo.toml b/services/google/Cargo.toml
index 0b3d829..cb8730c 100644
--- a/services/google/Cargo.toml
+++ b/services/google/Cargo.toml
@@ -26,18 +26,36 @@ license.workspace = true
 repository.workspace = true
 rust-version.workspace = true
 
+[package.metadata.docs.rs]
+all-features = true
+
+[features]
+default = []
+credential-access-boundary-client-side = [
+  "dep:aes-gcm",
+  "dep:base64",
+  "dep:futures",
+  "dep:prost",
+  "dep:zeroize",
+]
+
 [dependencies]
+aes-gcm = { workspace = true, features = ["zeroize"], optional = true }
+base64 = { workspace = true, optional = true }
 bytes = { workspace = true }
 form_urlencoded = { workspace = true }
+futures = { workspace = true, optional = true }
 http = { workspace = true }
 log = { workspace = true }
 percent-encoding = { workspace = true }
+prost = { workspace = true, optional = true }
 reqsign-aws-v4 = { workspace = true }
 reqsign-core = { workspace = true }
 rsa = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 tokio = { workspace = true, features = ["time"] }
+zeroize = { workspace = true, optional = true }
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 reqsign-core = { workspace = true, features = ["jwt"] }
diff --git a/services/google/src/credential_access_boundary.rs b/services/google/src/credential_access_boundary.rs
index 9352034..244798b 100644
--- a/services/google/src/credential_access_boundary.rs
+++ b/services/google/src/credential_access_boundary.rs
@@ -18,26 +18,24 @@
 use std::fmt::{self, Debug};
 use std::net::Ipv4Addr;
 use std::ops::{BitOr, BitOrAssign};
-use std::time::Duration;
 
-use form_urlencoded::Serializer;
-use http::header::{ACCEPT, CONTENT_TYPE};
-use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, Error, ErrorKind, GrantCredential, Result, SigningCredential};
-use serde::{Deserialize, Serialize};
+use reqsign_core::{Error, Result};
+use serde::Serialize;
 
-use crate::constants::TOKEN_OPERATION_HEADROOM;
-use crate::{Credential, Token};
+mod server_side;
+pub use server_side::ServerSideCredentialAccessBoundaryGranter;
+
+mod sts;
+
+#[cfg(feature = "credential-access-boundary-client-side")]
+mod client_side;
+#[cfg(feature = "credential-access-boundary-client-side")]
+pub use client_side::ClientSideCredentialAccessBoundaryGranter;
 
-const STS_ENDPOINT: &str = "https://sts.googleapis.com/v1/token";
-const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
-const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
-const TOKEN_EXCHANGE_HEADROOM: Duration = Duration::from_secs(10);
 const MAX_ACCESS_BOUNDARY_RULES: usize = 10;
 const MAX_ACCESS_BOUNDARY_CHARACTERS: usize = 2048;
 const MAX_CONDITION_CHARACTERS: usize = 2048;
 const MAX_OPTIONS_CHARACTERS: usize = 4 * 1024 * 1024;
-const MAX_ACCESS_TOKEN_LIFETIME: Duration = Duration::from_secs(12 * 60 * 60);
 
 const OBJECT_VIEWER_ROLE: u8 = 1 << 0;
 const OBJECT_CREATOR_ROLE: u8 = 1 << 1;
@@ -48,13 +46,12 @@ const ALL_ROLES: u8 =
 
 /// Typed Google Cloud Storage roles supported by Credential Access Boundaries.
 ///
-/// Google STS names the CAB field `availablePermissions`, but the protocol does
-/// not accept individual `storage.objects.*` permission names. It accepts IAM
-/// role identifiers prefixed with `inRole:`. These constants deliberately expose
-/// only the well-known predefined Cloud Storage object roles, preventing callers
-/// from injecting raw role identifiers or binding a grant to a mutable custom
-/// role.
-#[derive(Clone, Copy, Default, PartialEq, Eq)]
+/// The CAB schema names the field `availablePermissions`, but it does not accept
+/// individual `storage.objects.*` permission names. It accepts IAM role
+/// identifiers prefixed with `inRole:`. These constants deliberately expose only
+/// the well-known predefined Cloud Storage object roles, preventing callers from
+/// injecting raw role identifiers or binding a grant to a mutable custom role.
+#[derive(Clone, Copy, PartialEq, Eq)]
 pub struct CredentialAccessBoundaryPermissions(u8);
 
 impl CredentialAccessBoundaryPermissions {
@@ -217,7 +214,7 @@ impl CredentialAccessBoundaryGrant {
         self
     }
 
-    fn options_json(&self) -> Result<String> {
+    fn access_boundary(&self) -> Result<AccessBoundary> {
         if self.rules.is_empty() || self.rules.len() > MAX_ACCESS_BOUNDARY_RULES {
             return Err(Error::request_invalid(
                 "credential access boundary must contain between one and ten rules",
@@ -242,12 +239,20 @@ impl CredentialAccessBoundaryGrant {
         })?;
         if access_boundary_json.chars().count() > MAX_ACCESS_BOUNDARY_CHARACTERS {
             return Err(Error::request_invalid(
-                "credential access boundary exceeds the STS size limit",
+                "credential access boundary exceeds the size limit",
             ));
         }
+        Ok(access_boundary)
+    }
+
+    #[cfg(any(feature = "credential-access-boundary-client-side", test))]
+    fn validate(&self) -> Result<()> {
+        self.access_boundary().map(drop)
+    }
 
-        let options = StsOptions { access_boundary };
-        let json = serde_json::to_string(&options).map_err(|err| {
+    fn options_json(&self) -> Result<String> {
+        let access_boundary = self.access_boundary()?;
+        let json = serde_json::to_string(&StsOptions { access_boundary }).map_err(|err| {
             Error::unexpected("failed to serialize credential access boundary").with_source(err)
         })?;
         if json.chars().count() > MAX_OPTIONS_CHARACTERS {
@@ -380,656 +385,69 @@ fn build_prefix_condition(bucket: &str, prefix: &str) -> Result<AvailabilityCond
     );
     if expression.chars().count() > MAX_CONDITION_CHARACTERS {
         return Err(Error::request_invalid(
-            "credential access boundary condition exceeds the STS size limit",
+            "credential access boundary condition exceeds the size limit",
         ));
     }
 
     Ok(AvailabilityCondition { expression })
 }
 
-fn checked_expiration(response_time: Timestamp, expires_in: Duration) -> Result<Timestamp> {
-    let expires_in_seconds = i64::try_from(expires_in.as_secs())
-        .map_err(|_| Error::unexpected("credential access boundary STS expiration is invalid"))?;
-    let expiration_second = response_time
-        .as_second()
-        .checked_add(expires_in_seconds)
-        .ok_or_else(|| Error::unexpected("credential access boundary STS expiration is invalid"))?;
-    Timestamp::from_second(expiration_second)
-        .map_err(|_| Error::unexpected("credential access boundary STS expiration is invalid"))?;
-    Ok(response_time + expires_in)
-}
-
-/// Exchanges a Google OAuth access token for a CAB-downscoped access token.
-///
-/// The bound grant is stable configuration. Construct another granter (or use
-/// [`CredentialAccessBoundaryGranter::with_grant`]) for a different
-/// authorization decision. The server-side CAB exchange does not accept a
-/// requested lifetime, so [`reqsign_core::Granter::grant`] must be called with
-/// `None`.
-///
-/// The source must be a token-only [`Credential`] containing a Google-issued
-/// OAuth access token with a known absolute expiration and the
-/// `https://www.googleapis.com/auth/cloud-platform` scope. STS rejects tokens
-/// that already carry security attributes; the opaque token string does not
-/// expose enough information to detect that case locally. Token-only input with
-/// known expiration keeps the core source cache's validity tied to the same
-/// material used by the exchange; credentials with unknown expiration or an
-/// attached service account are rejected before STS I/O.
-///
-/// The returned [`Credential`] is token-only and can be consumed directly by
-/// the existing Google [`crate::RequestSigner`].
-///
-/// # Example
-///
-/// ```no_run
-/// use std::time::Duration;
-///
-/// use reqsign_core::{Context, Granter, time::Timestamp};
-/// use reqsign_google::{
-///     CredentialAccessBoundaryGrant, CredentialAccessBoundaryGranter,
-///     CredentialAccessBoundaryPermissions, TokenCredentialProvider,
-/// };
-///
-/// # async fn example() -> reqsign_core::Result<()> {
-/// let source = TokenCredentialProvider::new("source-oauth-token")
-///     .with_expires_at(Timestamp::now() + Duration::from_secs(3600));
-/// let grant = CredentialAccessBoundaryGrant::for_object_prefix(
-///     "example-bucket",
-///     "customer-a/",
-///     CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-/// );
-/// // Supply a Context configured with an HttpSend implementation.
-/// let context = Context::new();
-/// let credential = Granter::new(
-///     context,
-///     source,
-///     CredentialAccessBoundaryGranter::new(grant),
-/// )
-/// .grant(None)
-/// .await?;
-/// # let _ = credential;
-/// # Ok(())
-/// # }
-/// ```
-#[derive(Clone)]
-pub struct CredentialAccessBoundaryGranter {
-    grant: CredentialAccessBoundaryGrant,
-    #[cfg(test)]
-    now: Option<Timestamp>,
-    #[cfg(test)]
-    time_after_request: Option<Timestamp>,
-}
-
-impl Debug for CredentialAccessBoundaryGranter {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.debug_struct("CredentialAccessBoundaryGranter")
-            .finish_non_exhaustive()
-    }
-}
-
-impl CredentialAccessBoundaryGranter {
-    /// Create a granter for a bound Credential Access Boundary.
-    pub fn new(grant: CredentialAccessBoundaryGrant) -> Self {
-        Self {
-            grant,
-            #[cfg(test)]
-            now: None,
-            #[cfg(test)]
-            time_after_request: None,
-        }
-    }
-
-    /// Replace the bound grant.
-    pub fn with_grant(mut self, grant: CredentialAccessBoundaryGrant) -> Self {
-        self.grant = grant;
-        self
-    }
-
-    fn now(&self) -> Timestamp {
-        #[cfg(test)]
-        if let Some(now) = self.now {
-            return now;
-        }
-        Timestamp::now()
-    }
-
-    fn time_after_request(&self) -> Timestamp {
-        #[cfg(test)]
-        if let Some(now) = self.time_after_request {
-            return now;
-        }
-        #[cfg(test)]
-        if let Some(now) = self.now {
-            return now;
-        }
-        Timestamp::now()
-    }
-
-    #[cfg(test)]
-    fn with_time(mut self, now: Timestamp) -> Self {
-        self.now = Some(now);
-        self.time_after_request = Some(now);
-        self
-    }
-
-    #[cfg(test)]
-    fn with_time_after_request(mut self, now: Timestamp) -> Self {
-        self.time_after_request = Some(now);
-        self
-    }
-
-    fn source_token<'a>(
-        &self,
-        credential: &'a Credential,
-        required_until: Timestamp,
-    ) -> Result<&'a Token> {
-        if credential.service_account.is_some() {
-            return Err(Error::credential_invalid(
-                "credential access boundary exchange requires a token-only source credential",
-            ));
-        }
-        let token = credential.token.as_ref().ok_or_else(|| {
-            Error::credential_invalid(
-                "credential access boundary exchange requires an OAuth access token",
-            )
-        })?;
-        if token.access_token.is_empty() {
-            return Err(Error::credential_invalid(
-                "credential access boundary source access token is empty",
-            ));
-        }
-        if token.expires_at.is_none() {
-            return Err(Error::credential_invalid(
-                "credential access boundary source token expiration is required",
-            ));
-        }
-        if !token.is_valid_at(required_until) {
-            return Err(Error::credential_invalid(
-                "source OAuth access token expires before token exchange can complete",
-            ));
-        }
-        Ok(token)
-    }
-
-    fn build_request(
-        &self,
-        source_token: &str,
-        options: &str,
-    ) -> Result<http::Request<bytes::Bytes>> {
-        let body = Serializer::new(String::new())
-            .append_pair("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
-            .append_pair("requested_token_type", ACCESS_TOKEN_TYPE)
-            .append_pair("subject_token_type", ACCESS_TOKEN_TYPE)
-            .append_pair("subject_token", source_token)
-            .append_pair("options", options)
-            .finish();
-
-        http::Request::builder()
-            .method(http::Method::POST)
-            .uri(STS_ENDPOINT)
-            .header(ACCEPT, "application/json")
-            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
-            .body(body.into_bytes().into())
-            .map_err(|err| {
-                Error::unexpected("failed to build credential access boundary request")
-                    .with_source(err)
-            })
-    }
-
-    fn parse_response(
-        &self,
-        response: http::Response<bytes::Bytes>,
-        source: &Token,
-        response_time: Timestamp,
-    ) -> Result<Credential> {
-        if response.status() != http::StatusCode::OK {
-            return Err(sts_error(response.status(), response.body()));
-        }
-
-        let token_response: StsTokenResponse =
-            serde_json::from_slice(response.body()).map_err(|_| {
-                Error::unexpected("failed to parse credential access boundary STS response")
-            })?;
-
-        if token_response.access_token.is_empty()
-            || token_response.issued_token_type != ACCESS_TOKEN_TYPE
-            || token_response.token_type != "Bearer"
-        {
-            return Err(Error::unexpected(
-                "credential access boundary STS response is malformed",
-            ));
-        }
-
-        let source_expiration = source.expires_at.ok_or_else(|| {
-            Error::credential_invalid(
-                "credential access boundary source token expiration is required",
-            )
-        })?;
-        if source_expiration <= response_time {
-            return Err(Error::credential_invalid(
-                "source OAuth access token expired during token exchange",
-            ));
-        }
-
-        let response_expiration = token_response
-            .expires_in
-            .map(|expires_in| {
-                let expires_in = Duration::from_secs(expires_in);
-                if expires_in.is_zero() || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
-                    return Err(Error::unexpected(
-                        "credential access boundary STS expiration is invalid",
-                    ));
-                }
-                checked_expiration(response_time, expires_in)
-            })
-            .transpose()?;
-
-        let expires_at = response_expiration
-            .map(|response| response.min(source_expiration))
-            .unwrap_or(source_expiration);
-        if expires_at <= response_time {
-            return Err(Error::unexpected(
-                "credential access boundary STS token is already expired",
-            ));
-        }
-
-        let credential = Credential::with_token(Token {
-            access_token: token_response.access_token,
-            expires_at: Some(expires_at),
-        });
-        let required_until = checked_expiration(response_time, TOKEN_OPERATION_HEADROOM)?;
-        if !credential.is_valid_at(required_until) {
-            return Err(Error::credential_invalid(
-                "downscoped OAuth access token is not valid long enough for Google signing",
-            ));
-        }
-        Ok(credential)
-    }
-}
-
-impl GrantCredential for CredentialAccessBoundaryGranter {
-    type Credential = Credential;
-
-    fn required_valid_until(
-        &self,
-        _credential: &Self::Credential,
-        _expires_in: Option<Duration>,
-    ) -> Timestamp {
-        self.now() + TOKEN_EXCHANGE_HEADROOM + TOKEN_OPERATION_HEADROOM
-    }
-
-    async fn grant_credential(
-        &self,
-        ctx: &Context,
-        credential: &Self::Credential,
-        expires_in: Option<Duration>,
-    ) -> Result<Self::Credential> {
-        if expires_in.is_some() {
-            return Err(Error::request_invalid(
-                "credential access boundary exchange does not accept a requested lifetime",
-            ));
-        }
-
-        let options = self.grant.options_json()?;
-        let required_until = self.required_valid_until(credential, expires_in);
-        let source = self.source_token(credential, required_until)?;
-        let request = self.build_request(&source.access_token, &options)?;
-        let response = ctx.http_send(request).await.map_err(|err| {
-            Error::new(err.kind(), "credential access boundary STS request failed")
-                .set_retryable(err.is_retryable())
-        })?;
-        let response_time = self.time_after_request();
-        self.parse_response(response, source, response_time)
-    }
-}
-
-#[derive(Deserialize)]
-struct StsTokenResponse {
-    access_token: String,
-    issued_token_type: String,
-    token_type: String,
-    #[serde(default)]
-    expires_in: Option<u64>,
-}
-
-#[derive(Deserialize)]
-struct StsErrorResponse {
-    #[serde(default)]
-    error: Option<String>,
-}
-
-fn sts_error(status: http::StatusCode, body: &[u8]) -> Error {
-    let error_code = serde_json::from_slice::<StsErrorResponse>(body)
-        .ok()
-        .and_then(|response| response.error);
-    let recognized_code = match error_code.as_deref() {
-        Some(
-            code @ ("invalid_grant"
-            | "invalid_request"
-            | "invalid_target"
-            | "invalid_scope"
-            | "unsupported_grant_type"
-            | "unsupported_token_type"
-            | "unauthorized_client"
-            | "access_denied"
-            | "quota_exceeded"),
-        ) => Some(code),
-        _ => None,
-    };
-
-    let mut error = match recognized_code {
-        Some("invalid_grant") => Error::credential_invalid(
-            "credential access boundary token exchange rejected the source credential",
-        ),
-        Some(
-            "invalid_request"
-            | "invalid_target"
-            | "invalid_scope"
-            | "unsupported_grant_type"
-            | "unsupported_token_type",
-        ) => {
-            Error::request_invalid("credential access boundary token exchange rejected the request")
-        }
-        Some("unauthorized_client" | "access_denied") => {
-            Error::permission_denied("credential access boundary token exchange was denied")
-        }
-        Some("quota_exceeded") => {
-            Error::rate_limited("credential access boundary token exchange was rate limited")
-        }
-        _ if status == http::StatusCode::UNAUTHORIZED => Error::credential_invalid(
-            "credential access boundary token exchange rejected the source credential",
-        ),
-        _ if status == http::StatusCode::FORBIDDEN => {
-            Error::permission_denied("credential access boundary token exchange was denied")
-        }
-        _ if status == http::StatusCode::TOO_MANY_REQUESTS => {
-            Error::rate_limited("credential access boundary token exchange was rate limited")
-        }
-        _ => Error::unexpected("credential access boundary token exchange failed")
-            .set_retryable(status.is_server_error()),
-    }
-    .with_context(format!("sts_status: {}", status.as_u16()));
-
-    if let Some(code) = recognized_code {
-        error = error.with_context(format!("sts_error: {code}"));
-    }
-    if error.kind() == ErrorKind::Unexpected && status.is_server_error() {
-        error = error.set_retryable(true);
-    }
-    error
-}
-
 #[cfg(test)]
 mod tests {
-    use std::collections::{BTreeMap, VecDeque};
-    use std::fmt::Formatter;
-    use std::sync::atomic::{AtomicUsize, Ordering};
-    use std::sync::{Arc, Mutex};
-
-    use bytes::Bytes;
-    use http::header::{AUTHORIZATION, HeaderMap};
-    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer};
+    use reqsign_core::ErrorKind;
 
     use super::*;
-    use crate::{RequestSigner, ServiceAccount};
-
-    #[derive(Clone)]
-    struct CapturedRequest {
-        method: http::Method,
-        uri: http::Uri,
-        headers: HeaderMap,
-        body: Vec<u8>,
-    }
-
-    impl Debug for CapturedRequest {
-        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-            f.debug_struct("CapturedRequest")
-                .field("method", &self.method)
-                .field("uri", &self.uri)
-                .field("headers", &"REDACTED")
-                .field("body", &"REDACTED")
-                .finish()
-        }
-    }
-
-    #[derive(Clone)]
-    struct MockHttpSend {
-        calls: Arc<AtomicUsize>,
-        requests: Arc<Mutex<Vec<CapturedRequest>>>,
-        responses: Arc<Mutex<VecDeque<http::Response<Bytes>>>>,
-    }
-
-    impl Debug for MockHttpSend {
-        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-            f.debug_struct("MockHttpSend").finish_non_exhaustive()
-        }
-    }
-
-    impl MockHttpSend {
-        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
-            Self {
-                calls: Arc::new(AtomicUsize::new(0)),
-                requests: Arc::new(Mutex::new(Vec::new())),
-                responses: Arc::new(Mutex::new(responses.into_iter().collect())),
-            }
-        }
-
-        fn requests(&self) -> Vec<CapturedRequest> {
-            self.requests.lock().expect("lock poisoned").clone()
-        }
-    }
-
-    impl HttpSend for MockHttpSend {
-        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
-            self.calls.fetch_add(1, Ordering::SeqCst);
-            let (parts, body) = request.into_parts();
-            self.requests
-                .lock()
-                .expect("lock poisoned")
-                .push(CapturedRequest {
-                    method: parts.method,
-                    uri: parts.uri,
-                    headers: parts.headers,
-                    body: body.to_vec(),
-                });
-            self.responses
-                .lock()
-                .expect("lock poisoned")
-                .pop_front()
-                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
-        }
-    }
-
-    #[derive(Debug)]
-    struct SecretTransportError;
-
-    impl HttpSend for SecretTransportError {
-        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
-            Err(
-                Error::unexpected("transport captured subject_token=source-secret")
-                    .set_retryable(true),
-            )
-        }
-    }
 
-    #[derive(Clone)]
-    struct FixedCredentialProvider {
-        credential: Credential,
-        calls: Arc<AtomicUsize>,
-    }
-
-    impl Debug for FixedCredentialProvider {
-        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
-            f.debug_struct("FixedCredentialProvider")
-                .finish_non_exhaustive()
-        }
-    }
-
-    impl FixedCredentialProvider {
-        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
-            let calls = Arc::new(AtomicUsize::new(0));
-            (
-                Self {
-                    credential,
-                    calls: calls.clone(),
-                },
-                calls,
-            )
-        }
-    }
-
-    impl ProvideCredential for FixedCredentialProvider {
-        type Credential = Credential;
-
-        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
-            self.calls.fetch_add(1, Ordering::SeqCst);
-            Ok(Some(self.credential.clone()))
+    #[test]
+    fn validates_bucket_prefix_permission_and_rule_limits() {
+        let permissions = CredentialAccessBoundaryPermissions::OBJECT_VIEWER;
+        let invalid = [
+            CredentialAccessBoundaryGrant::for_bucket("ab", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("UPPER", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("bucket/name", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("-bucket", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("bucket-", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("192.168.0.1", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("goog-reserved", permissions),
+            CredentialAccessBoundaryGrant::for_bucket("bucket..name", permissions),
+            CredentialAccessBoundaryGrant::for_bucket(
+                "example-bucket",
+                CredentialAccessBoundaryPermissions(0),
+            ),
+            CredentialAccessBoundaryGrant::for_object_prefix("example-bucket", "", permissions),
+            CredentialAccessBoundaryGrant::for_object_prefix(
+                "example-bucket",
+                "line\nbreak",
+                permissions,
+            ),
+            CredentialAccessBoundaryGrant::for_object_prefix(
+                "example-bucket",
+                "x".repeat(1025),
+                permissions,
+            ),
+        ];
+        for grant in invalid {
+            let err = grant.validate().expect_err("invalid grant must fail");
+            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
         }
-    }
 
-    fn timestamp(value: &str) -> Timestamp {
-        value.parse().expect("timestamp must be valid")
-    }
-
-    fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
-        Credential::with_token(Token {
-            access_token: access_token.to_string(),
-            expires_at,
-        })
-    }
-
-    fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
-        http::Response::builder()
-            .status(status)
-            .body(body.into())
-            .expect("response must build")
-    }
-
-    fn success_response(access_token: &str, expires_in: Option<u64>) -> http::Response<Bytes> {
-        let mut value = serde_json::json!({
-            "access_token": access_token,
-            "issued_token_type": ACCESS_TOKEN_TYPE,
-            "token_type": "Bearer"
-        });
-        if let Some(expires_in) = expires_in {
-            value["expires_in"] = expires_in.into();
+        let mut maximum = CredentialAccessBoundaryGrant::for_bucket("bucket-0", permissions);
+        for index in 1..MAX_ACCESS_BOUNDARY_RULES {
+            maximum = maximum.with_bucket_rule(format!("bucket-{index}"), permissions);
         }
-        response(
-            http::StatusCode::OK,
-            serde_json::to_vec(&value).expect("response JSON must serialize"),
-        )
-    }
-
-    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
-        CredentialAccessBoundaryGrant::for_bucket(
-            "example-bucket",
-            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-        )
-    }
-
-    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
-        form_urlencoded::parse(&request.body).into_owned().collect()
-    }
-
-    fn access_boundary_characters(options: &str) -> usize {
-        let options: serde_json::Value =
-            serde_json::from_str(options).expect("options must be valid JSON");
-        serde_json::to_string(&options["accessBoundary"])
-            .expect("access boundary must serialize")
-            .chars()
-            .count()
-    }
-
-    fn output_token(credential: &Credential) -> &Token {
-        assert!(credential.service_account.is_none());
-        credential
-            .token
-            .as_ref()
-            .expect("granted credential must contain a token")
-    }
-
-    #[tokio::test]
-    async fn sends_exact_official_server_side_exchange_shape() {
-        let request_time = timestamp("2030-01-01T00:00:00Z");
-        let response_time = timestamp("2030-01-01T00:00:02Z");
-        let source_expiry = timestamp("2030-01-01T02:00:00Z");
-        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
-        let ctx = Context::new().with_http_send(http.clone());
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant())
-            .with_time(request_time)
-            .with_time_after_request(response_time);
-
-        let source = source_token("source-token", Some(source_expiry));
-        let output = operation
-            .grant_credential(&ctx, &source, None)
-            .await
-            .expect("token exchange must succeed");
-
-        let token = output_token(&output);
-        assert_eq!(token.access_token, "downscoped-token");
-        assert_eq!(token.expires_at, Some(timestamp("2030-01-01T01:00:02Z")));
-
-        let requests = http.requests();
-        assert_eq!(requests.len(), 1);
-        let request = &requests[0];
-        assert_eq!(request.method, http::Method::POST);
-        assert_eq!(request.uri, STS_ENDPOINT);
-        assert_eq!(request.headers[ACCEPT], "application/json");
-        assert_eq!(
-            request.headers[CONTENT_TYPE],
-            "application/x-www-form-urlencoded"
-        );
-        assert!(!request.headers.contains_key(AUTHORIZATION));
-        assert_eq!(
-            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
-            concat!(
-                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
-                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
-                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
-                "&subject_token=source-token",
-                "&options=%7B%22accessBoundary%22%3A%7B%22accessBoundaryRules%22%3A%5B%7B",
-                "%22availableResource%22%3A%22%2F%2Fstorage.googleapis.com%2Fprojects%2F_",
-                "%2Fbuckets%2Fexample-bucket%22%2C%22availablePermissions%22%3A%5B",
-                "%22inRole%3Aroles%2Fstorage.objectViewer%22%5D%7D%5D%7D%7D"
-            )
-        );
-    }
+        maximum
+            .validate()
+            .expect("ten valid rules must be accepted");
 
-    #[tokio::test]
-    async fn form_encodes_source_token_and_options_as_distinct_fields() {
-        let now = timestamp("2030-01-01T00:00:00Z");
-        let source = "source+token/%=&options=broader";
-        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
-        let ctx = Context::new().with_http_send(http.clone());
-        let operation =
-            CredentialAccessBoundaryGranter::new(CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "tenant&rule=broader",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ))
-            .with_time(now);
-
-        operation
-            .grant_credential(
-                &ctx,
-                &source_token(source, Some(timestamp("2030-01-01T02:00:00Z"))),
-                None,
-            )
-            .await
-            .expect("token exchange must succeed");
-
-        let request = &http.requests()[0];
-        let fields = form_fields(request);
-        assert_eq!(fields.len(), 5);
-        assert_eq!(fields["subject_token"], source);
-        assert!(fields["options"].contains("tenant&rule=broader"));
-        let raw = String::from_utf8(request.body.clone()).expect("form body must be UTF-8");
-        assert!(raw.contains("subject_token=source%2Btoken%2F%25%3D%26options%3Dbroader"));
-        assert!(!raw.contains("&options=broader&"));
+        let too_many = maximum.with_bucket_rule("bucket-10", permissions);
+        let err = too_many
+            .validate()
+            .expect_err("more than ten rules must fail");
+        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
     }
 
     #[test]
-    fn serializes_typed_roles_bucket_scope_and_prefix_scope() {
+    fn serializes_typed_roles_and_escapes_prefix_without_widening() {
         let permissions = CredentialAccessBoundaryPermissions::OBJECT_ADMIN
             | CredentialAccessBoundaryPermissions::OBJECT_VIEWER
             | CredentialAccessBoundaryPermissions::OBJECT_CREATOR;
@@ -1078,622 +496,36 @@ mod tests {
             CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
         )
         .options_json()
-        .expect("valid prefix must serialize");
-        let value: serde_json::Value = serde_json::from_str(&json).expect("options must be JSON");
-        let expression = value["accessBoundary"]["accessBoundaryRules"][0]["availabilityCondition"]
-            ["expression"]
+        .expect("prefix grant must serialize");
+        let options: serde_json::Value = serde_json::from_str(&json).expect("options must be JSON");
+        let expression = options["accessBoundary"]["accessBoundaryRules"][0]
+            ["availabilityCondition"]["expression"]
             .as_str()
             .expect("condition must be a string");
-        assert!(expression.contains("/objects//leading//nested/"));
+        assert!(expression.contains("objects//leading//nested/"));
         assert!(expression.ends_with(r#".startsWith("/leading//nested/")"#));
     }
 
     #[test]
-    fn enforces_documented_access_boundary_size_limit() {
+    fn enforces_access_boundary_size_limit() {
         let permissions = CredentialAccessBoundaryPermissions::OBJECT_VIEWER;
-        let at_limit = CredentialAccessBoundaryGrant::for_object_prefix(
+        CredentialAccessBoundaryGrant::for_object_prefix(
             "example-bucket",
             "x".repeat(772),
             permissions,
         )
-        .with_bucket_rule("aaa", permissions);
-        let options = at_limit
-            .options_json()
-            .expect("2048-character access boundary must serialize");
-        assert_eq!(
-            access_boundary_characters(&options),
-            MAX_ACCESS_BOUNDARY_CHARACTERS
-        );
+        .with_bucket_rule("aaa", permissions)
+        .validate()
+        .expect("boundary at the documented size limit must be accepted");
 
-        let over_limit = CredentialAccessBoundaryGrant::for_object_prefix(
+        let err = CredentialAccessBoundaryGrant::for_object_prefix(
             "example-bucket",
             "x".repeat(773),
             permissions,
         )
-        .with_bucket_rule("aaa", permissions);
-        let err = over_limit
-            .options_json()
-            .expect_err("access boundary over 2048 characters must fail");
-        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
-    }
-
-    #[test]
-    fn accepts_ten_rules_within_the_documented_size_limit() {
-        let mut grant = viewer_bucket_grant();
-        for index in 1..MAX_ACCESS_BOUNDARY_RULES {
-            grant = grant.with_bucket_rule(
-                format!("bucket-{index}"),
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            );
-        }
-
-        let options: serde_json::Value = serde_json::from_str(
-            &grant
-                .options_json()
-                .expect("documented maximums must serialize"),
-        )
-        .expect("options must be JSON");
-        assert_eq!(
-            options["accessBoundary"]["accessBoundaryRules"]
-                .as_array()
-                .expect("rules must be an array")
-                .len(),
-            MAX_ACCESS_BOUNDARY_RULES
-        );
-    }
-
-    #[tokio::test]
-    async fn rejects_invalid_grants_and_lifetime_before_sts_io() {
-        let now = timestamp("2030-01-01T00:00:00Z");
-        let source = source_token("source-token", Some(timestamp("2030-01-01T01:00:00Z")));
-        let http = MockHttpSend::new([]);
-        let ctx = Context::new().with_http_send(http.clone());
-
-        let mut invalid_grants = vec![
-            CredentialAccessBoundaryGrant::for_bucket(
-                "ab",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "UPPER",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "bucket/name",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "-bucket",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "bucket-",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "192.168.0.1",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "goog-reserved",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "g00gle-reserved",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "bucket..name",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "bucket-.name",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "bucket_name.example",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_bucket(
-                "example-bucket",
-                CredentialAccessBoundaryPermissions::default(),
-            ),
-            CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "x".repeat(1025),
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "界".repeat(342),
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "line\nbreak",
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "\\".repeat(1024),
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            ),
-            CredentialAccessBoundaryGrant::for_object_prefix(
-                "example-bucket",
-                "x".repeat(773),
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            )
-            .with_bucket_rule("aaa", CredentialAccessBoundaryPermissions::OBJECT_VIEWER),
-        ];
-
-        let mut too_many = viewer_bucket_grant();
-        for index in 1..=10 {
-            too_many = too_many.with_bucket_rule(
-                format!("bucket-{index}"),
-                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-            );
-        }
-        invalid_grants.push(too_many);
-
-        for grant in invalid_grants {
-            let err = CredentialAccessBoundaryGranter::new(grant)
-                .with_time(now)
-                .grant_credential(&ctx, &source, None)
-                .await
-                .expect_err("invalid grant must fail");
-            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
-        }
-
-        let err = CredentialAccessBoundaryGranter::new(viewer_bucket_grant())
-            .with_time(now)
-            .grant_credential(&ctx, &source, Some(Duration::from_secs(60)))
-            .await
-            .expect_err("caller-selected lifetime must fail");
+        .with_bucket_rule("aaa", permissions)
+        .validate()
+        .expect_err("boundary over the documented size limit must fail");
         assert_eq!(err.kind(), ErrorKind::RequestInvalid);
-        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
-    }
-
-    #[tokio::test]
-    async fn rejects_incompatible_or_expiring_source_before_sts_io() {
-        let now = timestamp("2030-01-01T00:00:00Z");
-        let http = MockHttpSend::new([]);
-        let ctx = Context::new().with_http_send(http.clone());
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
-
-        let service_account = Credential::with_service_account(ServiceAccount {
-            private_key: "source-private-key".to_string(),
-            client_email: "source@example.iam.gserviceaccount.com".to_string(),
-        });
-        let mut mixed = source_token(
-            "mixed-source-token",
-            Some(timestamp("2030-01-01T01:00:00Z")),
-        );
-        mixed.service_account = Some(ServiceAccount {
-            private_key: "mixed-source-private-key".to_string(),
-            client_email: "mixed-source@example.iam.gserviceaccount.com".to_string(),
-        });
-        let empty_token = source_token("", Some(timestamp("2030-01-01T01:00:00Z")));
-        let expiring_token = source_token("source-token", Some(timestamp("2030-01-01T00:00:05Z")));
-        let unknown_expiration = source_token("unknown-expiration", None);
-
-        for source in [
-            service_account,
-            mixed,
-            empty_token,
-            expiring_token,
-            unknown_expiration,
-        ] {
-            let err = operation
-                .grant_credential(&ctx, &source, None)
-                .await
-                .expect_err("incompatible source must fail");
-            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
-            assert!(!format!("{err:?}").contains("source-private-key"));
-            assert!(!format!("{err:?}").contains("mixed-source-private-key"));
-            assert!(!format!("{err:?}").contains("mixed-source-token"));
-            assert!(!format!("{err:?}").contains("source-token"));
-            assert!(!format!("{err:?}").contains("unknown-expiration"));
-        }
-        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
-    }
-
-    #[tokio::test]
-    async fn derives_and_clamps_expiration_at_response_time() {
-        let request_time = timestamp("2030-01-01T00:00:00Z");
-        let response_time = timestamp("2030-01-01T00:00:05Z");
-        let source_expiry = timestamp("2030-01-01T00:10:00Z");
-        let http = MockHttpSend::new([
-            success_response("from-source-expiry", None),
-            success_response("clamped-to-source", Some(3600)),
-        ]);
-        let ctx = Context::new().with_http_send(http);
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant())
-            .with_time(request_time)
-            .with_time_after_request(response_time);
-        let source = source_token("source-token", Some(source_expiry));
-
-        let inherited = operation
-            .grant_credential(&ctx, &source, None)
-            .await
-            .expect("missing expires_in must inherit source expiry");
-        assert_eq!(output_token(&inherited).expires_at, Some(source_expiry));
-
-        let clamped = operation
-            .grant_credential(&ctx, &source, None)
-            .await
-            .expect("output must clamp to source expiry");
-        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
-    }
-
-    #[tokio::test]
-    async fn accepts_maximum_documented_access_token_lifetime() {
-        let response_time = timestamp("2030-01-01T00:00:05Z");
-        let http = MockHttpSend::new([success_response(
-            "downscoped-token",
-            Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
-        )]);
-        let output = CredentialAccessBoundaryGranter::new(viewer_bucket_grant())
-            .with_time(timestamp("2030-01-01T00:00:00Z"))
-            .with_time_after_request(response_time)
-            .grant_credential(
-                &Context::new().with_http_send(http),
-                &source_token("source-token", Some(timestamp("2030-01-02T00:00:00Z"))),
-                None,
-            )
-            .await
-            .expect("maximum documented lifetime must be accepted");
-
-        assert_eq!(
-            output_token(&output).expires_at,
-            Some(timestamp("2030-01-01T12:00:05Z"))
-        );
-    }
-
-    #[tokio::test]
-    async fn rejects_expiration_timestamp_overflow() {
-        let http = MockHttpSend::new([success_response("downscoped-token", Some(2))]);
-        let err = CredentialAccessBoundaryGranter::new(viewer_bucket_grant())
-            .with_time(timestamp("2030-01-01T00:00:00Z"))
-            .with_time_after_request(timestamp("9999-12-30T21:59:59Z"))
-            .grant_credential(
-                &Context::new().with_http_send(http),
-                &source_token("source-token", Some(timestamp("9999-12-30T22:00:00Z"))),
-                None,
-            )
-            .await
-            .expect_err("expiration timestamp overflow must fail without panicking");
-
-        assert_eq!(err.kind(), ErrorKind::Unexpected);
-    }
-
-    #[tokio::test]
-    async fn validates_source_and_output_after_sts_io() {
-        let request_time = timestamp("2030-01-01T00:00:00Z");
-        let response_time = timestamp("2030-01-01T00:00:21Z");
-        let http = MockHttpSend::new([
-            success_response("downscoped-token", Some(3600)),
-            success_response("too-short-for-signer", Some(10)),
-        ]);
-        let ctx = Context::new().with_http_send(http.clone());
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant())
-            .with_time(request_time)
-            .with_time_after_request(response_time);
-
-        let err = operation
-            .grant_credential(
-                &ctx,
-                &source_token("expired-during-io", Some(timestamp("2030-01-01T00:00:21Z"))),
-                None,
-            )
-            .await
-            .expect_err("source expiry during I/O must fail");
-        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
-
-        let err = operation
-            .grant_credential(
-                &ctx,
-                &source_token("long-lived-source", Some(timestamp("2030-01-01T01:00:00Z"))),
-                None,
-            )
-            .await
-            .expect_err("output without signer headroom must fail");
-        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
-        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
-    }
-
-    #[tokio::test]
-    async fn rejects_malformed_success_responses_without_exposing_them() {
-        let now = timestamp("2030-01-01T00:00:00Z");
-        let bodies = [
-            br#"{}"#.as_slice(),
-            br#"{"access_token":"","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"wrong","token_type":"Bearer","expires_in":3600}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"MAC","expires_in":3600}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":0}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":43201}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":-1}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":"expiration-secret"}"#,
-            br#"{"access_token":"response-secret","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":18446744073709551616}"#,
-        ];
-        let http = MockHttpSend::new(
-            bodies
-                .into_iter()
-                .map(|body| response(http::StatusCode::OK, body.to_vec())),
-        );
-        let ctx = Context::new().with_http_send(http.clone());
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
-        let source = source_token("source-secret", Some(timestamp("2030-01-01T01:00:00Z")));
-
-        for _ in 0..bodies.len() {
-            let err = operation
-                .grant_credential(&ctx, &source, None)
-                .await
-                .expect_err("malformed response must fail");
-            let debug = format!("{err:?}");
-            assert_eq!(err.kind(), ErrorKind::Unexpected);
-            assert!(!debug.contains("source-secret"));
-            assert!(!debug.contains("response-secret"));
-            assert!(!debug.contains("expiration-secret"));
-        }
-        assert_eq!(http.calls.load(Ordering::SeqCst), bodies.len());
-    }
-
-    #[tokio::test]
-    async fn maps_sts_errors_without_exposing_raw_response() {
-        let now = timestamp("2030-01-01T00:00:00Z");
-        let cases = [
-            (
-                http::StatusCode::BAD_REQUEST,
-                r#"{"error":"invalid_grant","error_description":"source-secret"}"#,
-                ErrorKind::CredentialInvalid,
-                false,
-            ),
-            (
-                http::StatusCode::BAD_REQUEST,
-                r#"{"error":"invalid_request","error_description":"raw-options-secret"}"#,
-                ErrorKind::RequestInvalid,
-                false,
-            ),
-            (
-                http::StatusCode::FORBIDDEN,
-                r#"{"error":"access_denied","error_description":"authorization-secret"}"#,
-                ErrorKind::PermissionDenied,
-                false,
-            ),
-            (
-                http::StatusCode::TOO_MANY_REQUESTS,
-                r#"{"error":"quota_exceeded","error_description":"retry later"}"#,
-                ErrorKind::RateLimited,
-                true,
-            ),
-            (
-                http::StatusCode::SERVICE_UNAVAILABLE,
-                r#"{"error":"backend_error","error_description":"response-secret"}"#,
-                ErrorKind::Unexpected,
-                true,
-            ),
-        ];
-        let http = MockHttpSend::new(
-            cases
-                .iter()
-                .map(|(status, body, _, _)| response(*status, body.as_bytes().to_vec())),
-        );
-        let ctx = Context::new().with_http_send(http.clone());
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
-        let source = source_token("source-secret", Some(timestamp("2030-01-01T01:00:00Z")));
-
-        for (_, _, kind, retryable) in cases {
-            let err = operation
-                .grant_credential(&ctx, &source, None)
-                .await
-                .expect_err("STS error must fail");
-            assert_eq!(err.kind(), kind);
-            assert_eq!(err.is_retryable(), retryable);
-            let debug = format!("{err:?}");
-            for secret in [
-                "source-secret",
-                "raw-options-secret",
-                "authorization-secret",
-                "response-secret",
-            ] {
-                assert!(!debug.contains(secret));
-            }
-        }
-        assert_eq!(http.calls.load(Ordering::SeqCst), 5);
-    }
-
-    #[tokio::test]
-    async fn redacts_transport_errors_while_preserving_classification() {
-        let now = timestamp("2030-01-01T00:00:00Z");
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
-        let err = operation
-            .grant_credential(
-                &Context::new().with_http_send(SecretTransportError),
-                &source_token("source-secret", Some(timestamp("2030-01-01T01:00:00Z"))),
-                None,
-            )
-            .await
-            .expect_err("transport error must fail");
-
-        assert_eq!(err.kind(), ErrorKind::Unexpected);
-        assert!(err.is_retryable());
-        assert!(!format!("{err:?}").contains("source-secret"));
-    }
-
-    #[tokio::test]
-    async fn core_granter_caches_only_source_and_preserves_isolation() {
-        let now = Timestamp::now();
-        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
-        let source = source_token("source-secret", Some(source_expiry));
-        let (provider, provider_calls) = FixedCredentialProvider::new(source);
-        let first_http = MockHttpSend::new([
-            success_response("downscoped-1", Some(3600)),
-            success_response("downscoped-2", Some(3600)),
-            success_response("downscoped-3", Some(3600)),
-            success_response("downscoped-provider-replaced", Some(3600)),
-        ]);
-        let second_http = MockHttpSend::new([success_response("downscoped-isolated", Some(3600))]);
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
-        let granter = Granter::new(
-            Context::new().with_http_send(first_http.clone()),
-            provider,
-            operation.clone(),
-        );
-
-        let first = granter.grant(None).await.expect("first grant must succeed");
-        let second = granter
-            .clone()
-            .grant(None)
-            .await
-            .expect("clone grant must succeed");
-        let replaced = granter
-            .clone()
-            .with_credential_granter(operation.with_grant(
-                CredentialAccessBoundaryGrant::for_object_prefix(
-                    "example-bucket",
-                    "next/",
-                    CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
-                ),
-            ))
-            .grant(None)
-            .await
-            .expect("replacement operation must succeed");
-        let (replacement_provider, replacement_provider_calls) =
-            FixedCredentialProvider::new(source_token("replacement-source", Some(source_expiry)));
-        let provider_replaced = granter
-            .clone()
-            .with_credential_provider(replacement_provider)
-            .grant(None)
-            .await
-            .expect("replacement provider must succeed");
-        let isolated = granter
-            .with_context(Context::new().with_http_send(second_http.clone()))
-            .grant(None)
-            .await
-            .expect("replacement context must succeed");
-
-        assert_eq!(output_token(&first).access_token, "downscoped-1");
-        assert_eq!(output_token(&second).access_token, "downscoped-2");
-        assert_eq!(output_token(&replaced).access_token, "downscoped-3");
-        assert_eq!(
-            output_token(&provider_replaced).access_token,
-            "downscoped-provider-replaced"
-        );
-        assert_eq!(output_token(&isolated).access_token, "downscoped-isolated");
-        assert_eq!(provider_calls.load(Ordering::SeqCst), 2);
-        assert_eq!(replacement_provider_calls.load(Ordering::SeqCst), 1);
-        assert_eq!(first_http.calls.load(Ordering::SeqCst), 4);
-        assert_eq!(second_http.calls.load(Ordering::SeqCst), 1);
-
-        let replacement_fields = form_fields(&first_http.requests()[2]);
-        assert!(
-            replacement_fields["options"].contains("inRole:roles/storage.objectCreator"),
-            "replacement grant must use a distinct bound policy"
-        );
-        assert_eq!(
-            form_fields(&first_http.requests()[3])["subject_token"],
-            "replacement-source"
-        );
-    }
-
-    #[tokio::test]
-    async fn failed_reexchange_never_returns_previous_output() {
-        let now = Timestamp::now();
-        let source = source_token(
-            "source-secret",
-            Some(now + Duration::from_secs(2 * 60 * 60)),
-        );
-        let (provider, provider_calls) = FixedCredentialProvider::new(source);
-        let http = MockHttpSend::new([
-            success_response("first-downscoped-token", Some(3600)),
-            response(
-                http::StatusCode::SERVICE_UNAVAILABLE,
-                r#"{"error":"backend_error","error_description":"do not return stale token"}"#,
-            ),
-        ]);
-        let granter = Granter::new(
-            Context::new().with_http_send(http.clone()),
-            provider,
-            CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now),
-        );
-
-        let first = granter.grant(None).await.expect("first grant must succeed");
-        assert_eq!(output_token(&first).access_token, "first-downscoped-token");
-        let err = granter
-            .grant(None)
-            .await
-            .expect_err("failed exchange must not return the previous output");
-        assert_eq!(err.kind(), ErrorKind::Unexpected);
-        assert!(err.is_retryable());
-        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
-        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
-    }
-
-    #[tokio::test]
-    async fn granted_token_is_consumed_by_existing_google_signer() {
-        let now = Timestamp::now();
-        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
-        let operation = CredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
-        let output = operation
-            .grant_credential(
-                &Context::new().with_http_send(http),
-                &source_token("source-token", Some(now + Duration::from_secs(2 * 60 * 60))),
-                None,
-            )
-            .await
-            .expect("grant must succeed");
-        let (provider, _) = FixedCredentialProvider::new(output);
-        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
-        let mut parts =
-            http::Request::get("https://storage.googleapis.com/example-bucket/customer-a/object")
-                .body(())
-                .expect("request must build")
-                .into_parts()
-                .0;
-
-        signer
-            .sign(&mut parts, None)
-            .await
-            .expect("existing signer must consume downscoped token");
-        assert_eq!(parts.headers[AUTHORIZATION], "Bearer downscoped-token");
-        assert!(parts.headers[AUTHORIZATION].is_sensitive());
-    }
-
-    #[test]
-    fn debug_redacts_policy_and_credential_material() {
-        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
-            "sensitive-bucket",
-            "sensitive/prefix",
-            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
-        );
-        let operation = CredentialAccessBoundaryGranter::new(grant.clone());
-        let credential = source_token("sensitive-token", Some(Timestamp::now()));
-        let mock = MockHttpSend::new([]);
-        let captured = CapturedRequest {
-            method: http::Method::POST,
-            uri: STS_ENDPOINT.parse().expect("URI must parse"),
-            headers: HeaderMap::new(),
-            body: b"subject_token=sensitive-token".to_vec(),
-        };
-
-        for (debug, secret) in [
-            (format!("{grant:?}"), "sensitive-bucket"),
-            (format!("{grant:?}"), "sensitive/prefix"),
-            (format!("{operation:?}"), "sensitive-bucket"),
-            (format!("{credential:?}"), "sensitive-token"),
-            (format!("{mock:?}"), "sensitive-token"),
-            (format!("{captured:?}"), "sensitive-token"),
-        ] {
-            assert!(!debug.contains(secret));
-        }
-        assert_eq!(
-            format!("{:?}", CredentialAccessBoundaryPermissions::OBJECT_ADMIN),
-            "CredentialAccessBoundaryPermissions(REDACTED)"
-        );
     }
 }
diff --git a/services/google/src/credential_access_boundary/client_side.rs b/services/google/src/credential_access_boundary/client_side.rs
new file mode 100644
index 0000000..d1654b6
--- /dev/null
+++ b/services/google/src/credential_access_boundary/client_side.rs
@@ -0,0 +1,2554 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::VecDeque;
+use std::fmt::{self, Debug};
+use std::sync::{Arc, Mutex as SyncMutex, Weak};
+use std::time::Duration;
+
+use aes_gcm::aead::{Aead, Generate, KeyInit, array::Array};
+use aes_gcm::{Aes128Gcm, Aes256Gcm};
+use base64::Engine;
+use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
+use form_urlencoded::Serializer;
+use futures::lock::Mutex;
+use http::header::{ACCEPT, CONTENT_TYPE};
+use prost::Message;
+use reqsign_core::hash::hex_sha256;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, GrantCredential, Result, SigningCredential};
+use serde::Deserialize;
+use zeroize::{Zeroize, Zeroizing};
+
+use super::CredentialAccessBoundaryGrant;
+use super::sts::{
+    ACCESS_TOKEN_TYPE, MAX_ACCESS_TOKEN_LIFETIME, STS_ENDPOINT, TOKEN_EXCHANGE_GRANT_TYPE,
+    checked_expiration, sts_error,
+};
+use crate::constants::TOKEN_OPERATION_HEADROOM;
+use crate::{Credential, Token};
+
+const ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE: &str =
+    "urn:ietf:params:oauth:token-type:access_boundary_intermediary_token";
+const TOKEN_EXCHANGE_HEADROOM: Duration = Duration::from_secs(10);
+const MAX_MINIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(12 * 60 * 60 - 1);
+const DEFAULT_MINIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 60);
+const INTERMEDIARY_CACHE_CAPACITY: usize = 64;
+const MAX_SESSION_KEY_ENCODED_BYTES: usize = 64 * 1024;
+const MAX_SESSION_KEYSET_KEYS: usize = 32;
+const AES_GCM_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.AesGcmKey";
+const TINK_KEY_STATUS_ENABLED: i32 = 1;
+const TINK_KEY_MATERIAL_SYMMETRIC: i32 = 1;
+const TINK_PREFIX: i32 = 1;
+const LEGACY_PREFIX: i32 = 2;
+const RAW_PREFIX: i32 = 3;
+const CRUNCHY_PREFIX: i32 = 4;
+const AES_GCM_KEY_VERSION: u32 = 0;
+const AES_GCM_NONCE_BYTES: usize = 12;
+
+#[derive(Deserialize)]
+struct StsTokenResponse {
+    access_token: String,
+    issued_token_type: String,
+    token_type: String,
+    #[serde(default)]
+    expires_in: Option<u64>,
+    #[serde(default)]
+    access_boundary_session_key: Option<String>,
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct IntermediaryCacheKey {
+    endpoint: &'static str,
+    source_authority: String,
+    source_expires_at: Timestamp,
+}
+
+struct IntermediaryCredentials {
+    access_token: Zeroizing<String>,
+    expires_at: Timestamp,
+    aead_key: TinkAesGcmKey,
+}
+
+impl Debug for IntermediaryCredentials {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("IntermediaryCredentials")
+            .field("access_token", &"REDACTED")
+            .field("expires_at", &self.expires_at)
+            .field("aead_key", &"REDACTED")
+            .finish()
+    }
+}
+
+impl IntermediaryCredentials {
+    fn covers(&self, now: Timestamp, minimum_lifetime: Duration) -> Result<bool> {
+        let required_until = checked_expiration(now, minimum_lifetime)?;
+        Ok(self.expires_at > required_until)
+    }
+}
+
+#[derive(Default)]
+struct IntermediaryCache {
+    entries: VecDeque<(IntermediaryCacheKey, Arc<IntermediaryCredentials>)>,
+}
+
+impl IntermediaryCache {
+    fn find(
+        &mut self,
+        key: &IntermediaryCacheKey,
+        now: Timestamp,
+        minimum_lifetime: Duration,
+    ) -> Result<Option<Arc<IntermediaryCredentials>>> {
+        self.entries
+            .retain(|(_, credentials)| credentials.expires_at > now);
+        let mut index = None;
+        for (candidate_index, (candidate, credentials)) in self.entries.iter().enumerate() {
+            if candidate == key && credentials.covers(now, minimum_lifetime)? {
+                index = Some(candidate_index);
+                break;
+            }
+        }
+        let Some(index) = index else {
+            return Ok(None);
+        };
+
+        let entry = self.entries.remove(index).ok_or_else(|| {
+            Error::unexpected("client-side CAB intermediary cache is inconsistent")
+        })?;
+        let credentials = entry.1.clone();
+        self.entries.push_back(entry);
+        Ok(Some(credentials))
+    }
+
+    fn insert(
+        &mut self,
+        key: IntermediaryCacheKey,
+        credentials: Arc<IntermediaryCredentials>,
+        now: Timestamp,
+    ) {
+        self.entries
+            .retain(|(candidate, cached)| candidate != &key && cached.expires_at > now);
+        while self.entries.len() >= INTERMEDIARY_CACHE_CAPACITY {
+            self.entries.pop_front();
+        }
+        self.entries.push_back((key, credentials));
+    }
+
+    #[cfg(test)]
+    fn len(&self) -> usize {
+        self.entries.len()
+    }
+}
+
+#[derive(Default)]
+struct IntermediaryState {
+    cache: Mutex<IntermediaryCache>,
+    refresh_locks: RefreshLockRegistry,
+}
+
+impl IntermediaryState {
+    fn refresh_lock(&self, key: &IntermediaryCacheKey) -> RefreshLockLease {
+        let mut refresh_locks = self
+            .refresh_locks
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        if let Some(lock) = refresh_locks
+            .iter()
+            .find_map(|(candidate, lock)| (candidate == key).then(|| lock.upgrade()).flatten())
+        {
+            return RefreshLockLease {
+                key: key.clone(),
+                lock,
+                registry: self.refresh_locks.clone(),
+            };
+        }
+
+        let lock = Arc::new(Mutex::new(()));
+        refresh_locks.push((key.clone(), Arc::downgrade(&lock)));
+        RefreshLockLease {
+            key: key.clone(),
+            lock,
+            registry: self.refresh_locks.clone(),
+        }
+    }
+
+    #[cfg(test)]
+    fn refresh_lock_len(&self) -> usize {
+        self.refresh_locks
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
+            .len()
+    }
+}
+
+struct RefreshLockLease {
+    key: IntermediaryCacheKey,
+    lock: Arc<Mutex<()>>,
+    registry: RefreshLockRegistry,
+}
+
+type RefreshLockRegistry = Arc<SyncMutex<Vec<(IntermediaryCacheKey, Weak<Mutex<()>>)>>>;
+
+impl Drop for RefreshLockLease {
+    fn drop(&mut self) {
+        let mut registry = self
+            .registry
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        if Arc::strong_count(&self.lock) != 1 {
+            return;
+        }
+        registry.retain(|(candidate, weak)| {
+            candidate != &self.key
+                || weak
+                    .upgrade()
+                    .is_none_or(|lock| !Arc::ptr_eq(&lock, &self.lock))
+        });
+    }
+}
+
+/// Exchanges source credentials for intermediary material and generates Google
+/// Credential Access Boundary tokens locally.
+///
+/// The granter exchanges the source OAuth access token for an access-boundary
+/// intermediary token and session key when the cached intermediary no longer
+/// covers the requested lifetime, then creates every downscoped credential
+/// locally. It does not spawn background work; the first caller that needs a
+/// refresh performs the exchange. Refreshes for the same source authority,
+/// expiration, and endpoint are serialized, while unrelated partitions can
+/// proceed independently. After a failed refresh, the next waiting caller
+/// retries instead of receiving cached failure state. Clones and values produced
+/// with [`ClientSideCredentialAccessBoundaryGranter::with_grant`] share a bounded
+/// intermediary cache partitioned by the source token authority, its declared
+/// expiration, and the Google STS endpoint. Cancellation never installs a
+/// partially fetched intermediary, and refresh-lock registry entries live only
+/// while callers for that partition are active.
+///
+/// Each call performs fresh authenticated encryption and returns a distinct,
+/// token-only [`Credential`]. The output expiration exactly matches the
+/// intermediary expiration. The optional lifetime passed to
+/// [`reqsign_core::Granter::grant`] is treated as the minimum remaining output
+/// lifetime; it does not shorten the token. `None` uses the 30-minute default,
+/// which can be changed with
+/// [`ClientSideCredentialAccessBoundaryGranter::with_minimum_token_lifetime`].
+///
+/// The source must be a token-only Google OAuth access token for a service
+/// account, with a known absolute expiration and the Cloud Platform scope.
+/// Client-issued CAB tokens do not support user principals. STS rejects tokens
+/// that already carry security attributes; the opaque source token does not
+/// expose enough information to detect its principal, scope, or existing
+/// attributes locally. An intermediary response without `expires_in`, which STS
+/// uses for user access-token sources, is rejected.
+///
+/// Google currently returns a serialized Tink AEAD keyset as the session key.
+/// This implementation accepts enabled primary AES-GCM keys and fails closed for
+/// other key types.
+///
+/// # Example
+///
+/// ```no_run
+/// use std::time::Duration;
+///
+/// use reqsign_core::{Context, Granter, time::Timestamp};
+/// use reqsign_google::{
+///     ClientSideCredentialAccessBoundaryGranter, CredentialAccessBoundaryGrant,
+///     CredentialAccessBoundaryPermissions, TokenCredentialProvider,
+/// };
+///
+/// # async fn example() -> reqsign_core::Result<()> {
+/// let source = TokenCredentialProvider::new("source-oauth-token")
+///     .with_expires_at(Timestamp::now() + Duration::from_secs(3600));
+/// let grant = CredentialAccessBoundaryGrant::for_object_prefix(
+///     "example-bucket",
+///     "customer-a/",
+///     CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+/// );
+/// // Supply a Context configured with an HttpSend implementation.
+/// let context = Context::new();
+/// let credential = Granter::new(
+///     context,
+///     source,
+///     ClientSideCredentialAccessBoundaryGranter::new(grant),
+/// )
+/// .grant(None)
+/// .await?;
+/// # let _ = credential;
+/// # Ok(())
+/// # }
+/// ```
+#[derive(Clone)]
+pub struct ClientSideCredentialAccessBoundaryGranter {
+    grant: CredentialAccessBoundaryGrant,
+    minimum_token_lifetime: Duration,
+    intermediary_state: Arc<IntermediaryState>,
+    #[cfg(test)]
+    now: Option<Timestamp>,
+    #[cfg(test)]
+    time_after_request: Option<Timestamp>,
+    #[cfg(test)]
+    time_after_generation: Option<Timestamp>,
+    #[cfg(test)]
+    nonces: Option<Arc<std::sync::Mutex<VecDeque<[u8; AES_GCM_NONCE_BYTES]>>>>,
+}
+
+impl Debug for ClientSideCredentialAccessBoundaryGranter {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ClientSideCredentialAccessBoundaryGranter")
+            .finish_non_exhaustive()
+    }
+}
+
+impl ClientSideCredentialAccessBoundaryGranter {
+    /// Create a granter for a bound Credential Access Boundary.
+    pub fn new(grant: CredentialAccessBoundaryGrant) -> Self {
+        Self {
+            grant,
+            minimum_token_lifetime: DEFAULT_MINIMUM_TOKEN_LIFETIME,
+            intermediary_state: Arc::new(IntermediaryState::default()),
+            #[cfg(test)]
+            now: None,
+            #[cfg(test)]
+            time_after_request: None,
+            #[cfg(test)]
+            time_after_generation: None,
+            #[cfg(test)]
+            nonces: None,
+        }
+    }
+
+    /// Replace the bound grant while retaining the shared intermediary cache.
+    pub fn with_grant(mut self, grant: CredentialAccessBoundaryGrant) -> Self {
+        self.grant = grant;
+        self
+    }
+
+    /// Set the minimum remaining lifetime for outputs granted without an
+    /// explicit per-call lifetime.
+    ///
+    /// The value is rounded up to a whole second and must then be greater than
+    /// zero and strictly less than twelve hours. The largest accepted value is
+    /// 11 hours, 59 minutes, and 59 seconds. Validation occurs before STS I/O.
+    pub fn with_minimum_token_lifetime(mut self, lifetime: Duration) -> Self {
+        self.minimum_token_lifetime = lifetime;
+        self
+    }
+
+    fn now(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(now) = self.now {
+            return now;
+        }
+        Timestamp::now()
+    }
+
+    fn time_after_request(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(now) = self.time_after_request {
+            return now;
+        }
+        #[cfg(test)]
+        if let Some(now) = self.now {
+            return now;
+        }
+        Timestamp::now()
+    }
+
+    fn time_after_generation(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(now) = self.time_after_generation {
+            return now;
+        }
+        #[cfg(test)]
+        if let Some(now) = self.time_after_request {
+            return now;
+        }
+        #[cfg(test)]
+        if let Some(now) = self.now {
+            return now;
+        }
+        Timestamp::now()
+    }
+
+    #[cfg(test)]
+    fn with_time(mut self, now: Timestamp) -> Self {
+        self.now = Some(now);
+        self.time_after_request = Some(now);
+        self.time_after_generation = Some(now);
+        self
+    }
+
+    #[cfg(test)]
+    fn with_time_after_request(mut self, now: Timestamp) -> Self {
+        self.time_after_request = Some(now);
+        self.time_after_generation = Some(now);
+        self
+    }
+
+    #[cfg(test)]
+    fn with_time_after_generation(mut self, now: Timestamp) -> Self {
+        self.time_after_generation = Some(now);
+        self
+    }
+
+    #[cfg(test)]
+    fn with_nonces(mut self, nonces: impl IntoIterator<Item = [u8; AES_GCM_NONCE_BYTES]>) -> Self {
+        self.nonces = Some(Arc::new(std::sync::Mutex::new(
+            nonces.into_iter().collect(),
+        )));
+        self
+    }
+
+    fn effective_minimum_lifetime(&self, expires_in: Option<Duration>) -> Result<Duration> {
+        let requested = expires_in.unwrap_or(self.minimum_token_lifetime);
+        if requested.is_zero() {
+            return Err(Error::request_invalid(
+                "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
+            ));
+        }
+        let whole_seconds = requested
+            .as_secs()
+            .checked_add(u64::from(requested.subsec_nanos() != 0))
+            .ok_or_else(|| {
+                Error::request_invalid(
+                    "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
+                )
+        })?;
+        let requested = Duration::from_secs(whole_seconds).max(TOKEN_OPERATION_HEADROOM);
+        if requested > MAX_MINIMUM_TOKEN_LIFETIME {
+            return Err(Error::request_invalid(
+                "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
+            ));
+        }
+        Ok(requested)
+    }
+
+    fn source_token<'a>(
+        &self,
+        credential: &'a Credential,
+        required_until: Timestamp,
+    ) -> Result<&'a Token> {
+        if credential.service_account.is_some() {
+            return Err(Error::credential_invalid(
+                "client-side credential access boundary requires a token-only source credential",
+            ));
+        }
+        let token = credential.token.as_ref().ok_or_else(|| {
+            Error::credential_invalid(
+                "client-side credential access boundary requires an OAuth access token",
+            )
+        })?;
+        if token.access_token.is_empty() {
+            return Err(Error::credential_invalid(
+                "client-side credential access boundary source access token is empty",
+            ));
+        }
+        if token.expires_at.is_none() {
+            return Err(Error::credential_invalid(
+                "client-side credential access boundary source token expiration is required",
+            ));
+        }
+        if !token.is_valid_at(required_until) {
+            return Err(Error::credential_invalid(
+                "source OAuth access token expires before the client-side CAB intermediary exchange can complete",
+            ));
+        }
+        Ok(token)
+    }
+
+    fn build_intermediary_request(
+        &self,
+        source_token: &str,
+    ) -> Result<http::Request<bytes::Bytes>> {
+        let body = Serializer::new(String::new())
+            .append_pair("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
+            .append_pair(
+                "requested_token_type",
+                ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
+            )
+            .append_pair("subject_token_type", ACCESS_TOKEN_TYPE)
+            .append_pair("subject_token", source_token)
+            .finish();
+
+        http::Request::builder()
+            .method(http::Method::POST)
+            .uri(STS_ENDPOINT)
+            .header(ACCEPT, "application/json")
+            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
+            .body(body.into_bytes().into())
+            .map_err(|err| {
+                Error::unexpected("failed to build client-side CAB intermediary request")
+                    .with_source(err)
+            })
+    }
+
+    fn parse_intermediary_response(
+        &self,
+        response: http::Response<bytes::Bytes>,
+        source: &Token,
+        response_time: Timestamp,
+        minimum_lifetime: Duration,
+    ) -> Result<IntermediaryCredentials> {
+        if response.status() != http::StatusCode::OK {
+            return Err(sts_error(response.status(), response.body()));
+        }
+
+        let mut token_response: StsTokenResponse = serde_json::from_slice(response.body())
+            .map_err(|_| {
+                Error::unexpected("failed to parse client-side CAB intermediary STS response")
+            })?;
+        if token_response.access_token.is_empty()
+            || token_response.issued_token_type != ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
+            || token_response.token_type != "Bearer"
+        {
+            return Err(Error::unexpected(
+                "client-side CAB intermediary STS response is malformed",
+            ));
+        }
+        let session_key = Zeroizing::new(
+            token_response
+                .access_boundary_session_key
+                .take()
+                .filter(|value| !value.is_empty())
+                .ok_or_else(|| {
+                    Error::unexpected("client-side CAB intermediary STS response is malformed")
+                })?,
+        );
+
+        let source_expiration = source.expires_at.ok_or_else(|| {
+            Error::credential_invalid(
+                "client-side credential access boundary source token expiration is required",
+            )
+        })?;
+        if source_expiration <= response_time {
+            return Err(Error::credential_invalid(
+                "source OAuth access token expired during the client-side CAB intermediary exchange",
+            ));
+        }
+
+        let expires_in = token_response.expires_in.ok_or_else(|| {
+            Error::credential_invalid(
+                "client-side credential access boundary requires a service-account source with an explicit STS expiration",
+            )
+        })?;
+        let expires_in = Duration::from_secs(expires_in);
+        if expires_in.is_zero() || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
+            return Err(Error::unexpected(
+                "client-side CAB intermediary STS expiration is invalid",
+            ));
+        }
+        let expires_at = checked_expiration(response_time, expires_in)?.min(source_expiration);
+        let required_until = checked_expiration(response_time, minimum_lifetime)?;
+        if expires_at <= required_until {
+            return Err(Error::credential_invalid(
+                "client-side CAB intermediary token is not valid for the minimum output lifetime",
+            ));
+        }
+
+        let aead_key = TinkAesGcmKey::parse(&session_key)?;
+        Ok(IntermediaryCredentials {
+            access_token: Zeroizing::new(token_response.access_token),
+            expires_at,
+            aead_key,
+        })
+    }
+
+    fn cache_key(&self, source: &Token) -> Result<IntermediaryCacheKey> {
+        let source_expires_at = source.expires_at.ok_or_else(|| {
+            Error::credential_invalid(
+                "client-side credential access boundary source token expiration is required",
+            )
+        })?;
+        Ok(IntermediaryCacheKey {
+            endpoint: STS_ENDPOINT,
+            source_authority: hex_sha256(source.access_token.as_bytes()),
+            source_expires_at,
+        })
+    }
+
+    async fn intermediary_credentials(
+        &self,
+        ctx: &Context,
+        source: &Token,
+        minimum_lifetime: Duration,
+    ) -> Result<Arc<IntermediaryCredentials>> {
+        let cache_key = self.cache_key(source)?;
+        let now = self.now();
+        if let Some(credentials) =
+            self.intermediary_state
+                .cache
+                .lock()
+                .await
+                .find(&cache_key, now, minimum_lifetime)?
+        {
+            return Ok(credentials);
+        }
+
+        let refresh_lock = self.intermediary_state.refresh_lock(&cache_key);
+        let _refresh_guard = refresh_lock.lock.lock().await;
+        let now = self.now();
+        if let Some(credentials) =
+            self.intermediary_state
+                .cache
+                .lock()
+                .await
+                .find(&cache_key, now, minimum_lifetime)?
+        {
+            return Ok(credentials);
+        }
+
+        let required_until = checked_expiration(
+            now,
+            minimum_lifetime.saturating_add(TOKEN_EXCHANGE_HEADROOM),
+        )?;
+        if !source.is_valid_at(required_until) {
+            return Err(Error::credential_invalid(
+                "source OAuth access token expires before the client-side CAB intermediary exchange can complete",
+            ));
+        }
+
+        let request = self.build_intermediary_request(&source.access_token)?;
+        let response = ctx.http_send(request).await.map_err(|err| {
+            Error::new(
+                err.kind(),
+                "client-side CAB intermediary STS request failed",
+            )
+            .set_retryable(err.is_retryable())
+        })?;
+        let response_time = self.time_after_request();
+        let credentials = Arc::new(self.parse_intermediary_response(
+            response,
+            source,
+            response_time,
+            minimum_lifetime,
+        )?);
+        self.intermediary_state.cache.lock().await.insert(
+            cache_key,
+            credentials.clone(),
+            response_time,
+        );
+        Ok(credentials)
+    }
+
+    fn next_nonce(&self) -> Result<[u8; AES_GCM_NONCE_BYTES]> {
+        #[cfg(test)]
+        if let Some(nonces) = &self.nonces {
+            return nonces
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .ok_or_else(|| Error::unexpected("test CAB nonce queue is empty"));
+        }
+
+        <[u8; AES_GCM_NONCE_BYTES]>::try_generate()
+            .map_err(|_| Error::unexpected("failed to generate client-side CAB encryption nonce"))
+    }
+
+    #[cfg(test)]
+    async fn cache_len(&self) -> usize {
+        self.intermediary_state.cache.lock().await.len()
+    }
+
+    #[cfg(test)]
+    fn refresh_lock_len(&self) -> usize {
+        self.intermediary_state.refresh_lock_len()
+    }
+}
+
+impl GrantCredential for ClientSideCredentialAccessBoundaryGranter {
+    type Credential = Credential;
+
+    fn required_valid_until(
+        &self,
+        _credential: &Self::Credential,
+        expires_in: Option<Duration>,
+    ) -> Timestamp {
+        let minimum_lifetime = self
+            .effective_minimum_lifetime(expires_in)
+            .unwrap_or(TOKEN_OPERATION_HEADROOM);
+        self.now() + minimum_lifetime + TOKEN_EXCHANGE_HEADROOM
+    }
+
+    async fn grant_credential(
+        &self,
+        ctx: &Context,
+        credential: &Self::Credential,
+        expires_in: Option<Duration>,
+    ) -> Result<Self::Credential> {
+        let minimum_lifetime = self.effective_minimum_lifetime(expires_in)?;
+        let restrictions = serialize_restrictions(&self.grant)?;
+        let required_until = checked_expiration(
+            self.now(),
+            minimum_lifetime.saturating_add(TOKEN_EXCHANGE_HEADROOM),
+        )?;
+        let source = self.source_token(credential, required_until)?;
+        let intermediary = self
+            .intermediary_credentials(ctx, source, minimum_lifetime)
+            .await?;
+
+        let nonce = self.next_nonce()?;
+        let encrypted = intermediary.aead_key.encrypt(&restrictions, &nonce)?;
+        let access_token = format!(
+            "{}.{}",
+            intermediary.access_token.as_str(),
+            URL_SAFE_NO_PAD.encode(encrypted)
+        );
+        let output = Credential::with_token(Token {
+            access_token,
+            expires_at: Some(intermediary.expires_at),
+        });
+
+        let completed_at = self.time_after_generation();
+        let required_until = checked_expiration(completed_at, minimum_lifetime)?;
+        if !output.is_valid_at(required_until) {
+            return Err(Error::credential_invalid(
+                "client-issued CAB token is not valid for the minimum output lifetime after generation",
+            ));
+        }
+        Ok(output)
+    }
+}
+
+struct TinkAesGcmKey {
+    output_prefix: Vec<u8>,
+    key_value: Zeroizing<Vec<u8>>,
+}
+
+impl Debug for TinkAesGcmKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("TinkAesGcmKey")
+            .field("output_prefix", &"REDACTED")
+            .field("key_value", &"REDACTED")
+            .finish()
+    }
+}
+
+impl TinkAesGcmKey {
+    fn parse(session_key: &str) -> Result<Self> {
+        if session_key.len() > MAX_SESSION_KEY_ENCODED_BYTES {
+            return Err(Error::unexpected(
+                "client-side CAB session key is malformed",
+            ));
+        }
+        let decoded = STANDARD
+            .decode(session_key)
+            .map(Zeroizing::new)
+            .map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
+        let mut keyset = TinkKeyset::decode(decoded.as_slice())
+            .map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
+        if keyset.keys.is_empty() || keyset.keys.len() > MAX_SESSION_KEYSET_KEYS {
+            zeroize_keyset(&mut keyset);
+            return Err(Error::unexpected(
+                "client-side CAB session key is malformed",
+            ));
+        }
+
+        let primary_indexes = keyset
+            .keys
+            .iter()
+            .enumerate()
+            .filter_map(|(index, key)| (key.key_id == keyset.primary_key_id).then_some(index))
+            .collect::<Vec<_>>();
+        if primary_indexes.len() != 1 {
+            zeroize_keyset(&mut keyset);
+            return Err(Error::unexpected(
+                "client-side CAB session key is malformed",
+            ));
+        }
+        let mut primary = keyset.keys.swap_remove(primary_indexes[0]);
+        zeroize_keyset(&mut keyset);
+        if primary.status != TINK_KEY_STATUS_ENABLED {
+            zeroize_key(&mut primary);
+            return Err(Error::unexpected(
+                "client-side CAB session key is malformed",
+            ));
+        }
+
+        let output_prefix = match primary.output_prefix_type {
+            TINK_PREFIX => {
+                let mut prefix = Vec::with_capacity(5);
+                prefix.push(1);
+                prefix.extend_from_slice(&primary.key_id.to_be_bytes());
+                prefix
+            }
+            LEGACY_PREFIX | CRUNCHY_PREFIX => {
+                let mut prefix = Vec::with_capacity(5);
+                prefix.push(0);
+                prefix.extend_from_slice(&primary.key_id.to_be_bytes());
+                prefix
+            }
+            RAW_PREFIX => Vec::new(),
+            _ => {
+                zeroize_key(&mut primary);
+                return Err(Error::unexpected(
+                    "client-side CAB session key is malformed",
+                ));
+            }
+        };
+
+        let mut key_data = primary
+            .key_data
+            .take()
+            .ok_or_else(|| Error::unexpected("client-side CAB session key is malformed"))?;
+        if key_data.type_url != AES_GCM_KEY_TYPE_URL
+            || key_data.key_material_type != TINK_KEY_MATERIAL_SYMMETRIC
+        {
+            key_data.value.zeroize();
+            return Err(Error::unexpected(
+                "client-side CAB session key uses an unsupported AEAD key",
+            ));
+        }
+        let aes_key = AesGcmKeyProto::decode(key_data.value.as_slice());
+        key_data.value.zeroize();
+        let mut aes_key =
+            aes_key.map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
+        if aes_key.version != AES_GCM_KEY_VERSION || !matches!(aes_key.key_value.len(), 16 | 32) {
+            aes_key.key_value.zeroize();
+            return Err(Error::unexpected(
+                "client-side CAB session key uses an unsupported AEAD key",
+            ));
+        }
+
+        Ok(Self {
+            output_prefix,
+            key_value: Zeroizing::new(aes_key.key_value),
+        })
+    }
+
+    fn encrypt(&self, restrictions: &[u8], nonce: &[u8; AES_GCM_NONCE_BYTES]) -> Result<Vec<u8>> {
+        let ciphertext = match self.key_value.len() {
+            16 => Aes128Gcm::new_from_slice(&self.key_value)
+                .map_err(|_| Error::unexpected("failed to initialize client-side CAB encryption"))?
+                .encrypt(&Array(*nonce), restrictions),
+            32 => Aes256Gcm::new_from_slice(&self.key_value)
+                .map_err(|_| Error::unexpected("failed to initialize client-side CAB encryption"))?
+                .encrypt(&Array(*nonce), restrictions),
+            _ => {
+                return Err(Error::unexpected(
+                    "client-side CAB session key uses an unsupported AEAD key",
+                ));
+            }
+        }
+        .map_err(|_| Error::unexpected("failed to encrypt client-side CAB restrictions"))?;
+
+        let mut encrypted =
+            Vec::with_capacity(self.output_prefix.len() + nonce.len() + ciphertext.len());
+        encrypted.extend_from_slice(&self.output_prefix);
+        encrypted.extend_from_slice(nonce);
+        encrypted.extend_from_slice(&ciphertext);
+        Ok(encrypted)
+    }
+}
+
+fn zeroize_keyset(keyset: &mut TinkKeyset) {
+    for key in &mut keyset.keys {
+        zeroize_key(key);
+    }
+}
+
+fn zeroize_key(key: &mut TinkKeysetKey) {
+    if let Some(key_data) = &mut key.key_data {
+        key_data.value.zeroize();
+    }
+}
+
+fn serialize_restrictions(grant: &CredentialAccessBoundaryGrant) -> Result<Vec<u8>> {
+    grant.validate()?;
+    let rules = grant
+        .rules
+        .iter()
+        .map(|rule| {
+            let available_resource = format!(
+                "//storage.googleapis.com/projects/_/buckets/{}",
+                rule.bucket
+            );
+            let available_permissions = rule
+                .permissions
+                .roles()?
+                .into_iter()
+                .map(str::to_owned)
+                .collect();
+            let compiled_availability_condition = rule
+                .object_prefix
+                .as_deref()
+                .map(|prefix| prefix_condition_expr(&rule.bucket, prefix));
+            Ok(ClientSideAccessBoundaryRule {
+                available_resource,
+                available_permissions,
+                compiled_availability_condition,
+            })
+        })
+        .collect::<Result<Vec<_>>>()?;
+    Ok(ClientSideAccessBoundary {
+        access_boundary_rules: rules,
+    }
+    .encode_to_vec())
+}
+
+fn prefix_condition_expr(bucket: &str, prefix: &str) -> CelExpr {
+    let object_resource_prefix = format!("projects/_/buckets/{bucket}/objects/{prefix}");
+    let resource_starts_with = call(
+        2,
+        Some(select(3, ident(4, "resource"), "name")),
+        "startsWith",
+        vec![string_constant(5, object_resource_prefix)],
+    );
+    let list_prefix = call(
+        6,
+        Some(call(
+            7,
+            Some(ident(8, "api")),
+            "getAttribute",
+            vec![
+                string_constant(9, "storage.googleapis.com/objectListPrefix"),
+                string_constant(10, ""),
+            ],
+        )),
+        "startsWith",
+        vec![string_constant(11, prefix)],
+    );
+    call(1, None, "_||_", vec![resource_starts_with, list_prefix])
+}
+
+fn ident(id: i64, name: impl Into<String>) -> CelExpr {
+    CelExpr {
+        id,
+        ident_expr: Some(CelIdent { name: name.into() }),
+        ..Default::default()
+    }
+}
+
+fn select(id: i64, operand: CelExpr, field: impl Into<String>) -> CelExpr {
+    CelExpr {
+        id,
+        select_expr: Some(CelSelect {
+            operand: Some(Box::new(operand)),
+            field: field.into(),
+            test_only: false,
+        }),
+        ..Default::default()
+    }
+}
+
+fn call(
+    id: i64,
+    target: Option<CelExpr>,
+    function: impl Into<String>,
+    args: Vec<CelExpr>,
+) -> CelExpr {
+    CelExpr {
+        id,
+        call_expr: Some(CelCall {
+            target: target.map(Box::new),
+            function: function.into(),
+            args,
+        }),
+        ..Default::default()
+    }
+}
+
+fn string_constant(id: i64, value: impl Into<String>) -> CelExpr {
+    CelExpr {
+        id,
+        const_expr: Some(CelConstant {
+            string_value: Some(value.into()),
+        }),
+        ..Default::default()
+    }
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct ClientSideAccessBoundary {
+    #[prost(message, repeated, tag = "1")]
+    access_boundary_rules: Vec<ClientSideAccessBoundaryRule>,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct ClientSideAccessBoundaryRule {
+    #[prost(string, tag = "1")]
+    available_resource: String,
+    #[prost(string, repeated, tag = "2")]
+    available_permissions: Vec<String>,
+    #[prost(message, optional, tag = "4")]
+    compiled_availability_condition: Option<CelExpr>,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct CelExpr {
+    #[prost(int64, tag = "2")]
+    id: i64,
+    #[prost(message, optional, tag = "3")]
+    const_expr: Option<CelConstant>,
+    #[prost(message, optional, tag = "4")]
+    ident_expr: Option<CelIdent>,
+    #[prost(message, optional, tag = "5")]
+    select_expr: Option<CelSelect>,
+    #[prost(message, optional, tag = "6")]
+    call_expr: Option<CelCall>,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct CelIdent {
+    #[prost(string, tag = "1")]
+    name: String,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct CelSelect {
+    #[prost(message, optional, boxed, tag = "1")]
+    operand: Option<Box<CelExpr>>,
+    #[prost(string, tag = "2")]
+    field: String,
+    #[prost(bool, tag = "3")]
+    test_only: bool,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct CelCall {
+    #[prost(message, optional, boxed, tag = "1")]
+    target: Option<Box<CelExpr>>,
+    #[prost(string, tag = "2")]
+    function: String,
+    #[prost(message, repeated, tag = "3")]
+    args: Vec<CelExpr>,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct CelConstant {
+    #[prost(string, optional, tag = "6")]
+    string_value: Option<String>,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct TinkKeyset {
+    #[prost(uint32, tag = "1")]
+    primary_key_id: u32,
+    #[prost(message, repeated, tag = "2")]
+    keys: Vec<TinkKeysetKey>,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct TinkKeysetKey {
+    #[prost(message, optional, tag = "1")]
+    key_data: Option<TinkKeyData>,
+    #[prost(int32, tag = "2")]
+    status: i32,
+    #[prost(uint32, tag = "3")]
+    key_id: u32,
+    #[prost(int32, tag = "4")]
+    output_prefix_type: i32,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct TinkKeyData {
+    #[prost(string, tag = "1")]
+    type_url: String,
+    #[prost(bytes = "vec", tag = "2")]
+    value: Vec<u8>,
+    #[prost(int32, tag = "3")]
+    key_material_type: i32,
+}
+
+#[derive(Clone, PartialEq, Message)]
+struct AesGcmKeyProto {
+    #[prost(uint32, tag = "1")]
+    version: u32,
+    #[prost(bytes = "vec", tag = "3")]
+    key_value: Vec<u8>,
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::{BTreeMap, HashSet, VecDeque};
+    use std::fmt::Formatter;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::{Arc, Mutex as StdMutex};
+
+    use bytes::Bytes;
+    use http::header::{AUTHORIZATION, HeaderMap};
+    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer, time::Timestamp};
+    use tokio::sync::Semaphore;
+
+    use super::*;
+    use crate::{CredentialAccessBoundaryPermissions, RequestSigner, ServiceAccount};
+
+    // This is the access-boundary session key returned by Google's
+    // MockStsTransport in google-auth-library-java. It is a serialized Tink
+    // AES-256-GCM keyset and contains no production credential material.
+    const GOOGLE_AUTH_LIBRARY_SESSION_KEY: &str = concat!(
+        "CPaEhYsKEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQW",
+        "VzR2NtS2V5EiIaIMx8syvGIGGu5yvrdq/I0Q9ZWIR1oqJXFnDFxHuwX4SEGAEQARj2hIWLCiAB"
+    );
+
+    #[derive(Clone)]
+    struct CapturedRequest {
+        method: http::Method,
+        uri: http::Uri,
+        headers: HeaderMap,
+        body: Vec<u8>,
+    }
+
+    impl Debug for CapturedRequest {
+        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+            f.debug_struct("CapturedRequest")
+                .field("method", &self.method)
+                .field("uri", &self.uri)
+                .field("headers", &"REDACTED")
+                .field("body", &"REDACTED")
+                .finish()
+        }
+    }
+
+    #[derive(Clone)]
+    struct RequestGate {
+        started: Arc<Semaphore>,
+        release: Arc<Semaphore>,
+    }
+
+    impl RequestGate {
+        async fn wait_started(&self) {
+            self.started
+                .acquire()
+                .await
+                .expect("started semaphore must remain open")
+                .forget();
+        }
+
+        fn release_one(&self) {
+            self.release.add_permits(1);
+        }
+    }
+
+    #[derive(Clone)]
+    struct MockHttpSend {
+        calls: Arc<AtomicUsize>,
+        requests: Arc<StdMutex<Vec<CapturedRequest>>>,
+        responses: Arc<StdMutex<VecDeque<http::Response<Bytes>>>>,
+        gate: Option<RequestGate>,
+    }
+
+    impl Debug for MockHttpSend {
+        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+            f.debug_struct("MockHttpSend").finish_non_exhaustive()
+        }
+    }
+
+    impl MockHttpSend {
+        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
+            Self {
+                calls: Arc::new(AtomicUsize::new(0)),
+                requests: Arc::new(StdMutex::new(Vec::new())),
+                responses: Arc::new(StdMutex::new(responses.into_iter().collect())),
+                gate: None,
+            }
+        }
+
+        fn gated(
+            responses: impl IntoIterator<Item = http::Response<Bytes>>,
+        ) -> (Self, RequestGate) {
+            let gate = RequestGate {
+                started: Arc::new(Semaphore::new(0)),
+                release: Arc::new(Semaphore::new(0)),
+            };
+            let mut http = Self::new(responses);
+            http.gate = Some(gate.clone());
+            (http, gate)
+        }
+
+        fn requests(&self) -> Vec<CapturedRequest> {
+            self.requests.lock().expect("lock poisoned").clone()
+        }
+    }
+
+    impl HttpSend for MockHttpSend {
+        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            self.calls.fetch_add(1, Ordering::SeqCst);
+            let (parts, body) = request.into_parts();
+            self.requests
+                .lock()
+                .expect("lock poisoned")
+                .push(CapturedRequest {
+                    method: parts.method,
+                    uri: parts.uri,
+                    headers: parts.headers,
+                    body: body.to_vec(),
+                });
+            if let Some(gate) = &self.gate {
+                gate.started.add_permits(1);
+                gate.release
+                    .acquire()
+                    .await
+                    .expect("release semaphore must remain open")
+                    .forget();
+            }
+            self.responses
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
+        }
+    }
+
+    #[derive(Debug)]
+    struct SecretTransportError;
+
+    impl HttpSend for SecretTransportError {
+        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            Err(
+                Error::unexpected("transport retained subject_token=source-secret")
+                    .set_retryable(true),
+            )
+        }
+    }
+
+    #[derive(Clone)]
+    struct FixedCredentialProvider {
+        credential: Credential,
+        calls: Arc<AtomicUsize>,
+    }
+
+    impl Debug for FixedCredentialProvider {
+        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+            f.debug_struct("FixedCredentialProvider")
+                .finish_non_exhaustive()
+        }
+    }
+
+    impl FixedCredentialProvider {
+        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
+            let calls = Arc::new(AtomicUsize::new(0));
+            (
+                Self {
+                    credential,
+                    calls: calls.clone(),
+                },
+                calls,
+            )
+        }
+    }
+
+    impl ProvideCredential for FixedCredentialProvider {
+        type Credential = Credential;
+
+        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
+            self.calls.fetch_add(1, Ordering::SeqCst);
+            Ok(Some(self.credential.clone()))
+        }
+    }
+
+    fn timestamp(value: &str) -> Timestamp {
+        value.parse().expect("timestamp must be valid")
+    }
+
+    fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
+        Credential::with_token(Token {
+            access_token: access_token.to_string(),
+            expires_at,
+        })
+    }
+
+    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
+        CredentialAccessBoundaryGrant::for_bucket(
+            "example-bucket",
+            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+        )
+    }
+
+    fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
+        http::Response::builder()
+            .status(status)
+            .body(body.into())
+            .expect("response must build")
+    }
+
+    fn success_response(
+        intermediary_token: &str,
+        expires_in: Option<u64>,
+    ) -> http::Response<Bytes> {
+        success_response_with_key(
+            intermediary_token,
+            GOOGLE_AUTH_LIBRARY_SESSION_KEY,
+            expires_in,
+        )
+    }
+
+    fn success_response_with_key(
+        intermediary_token: &str,
+        session_key: &str,
+        expires_in: Option<u64>,
+    ) -> http::Response<Bytes> {
+        let mut value = serde_json::json!({
+            "access_token": intermediary_token,
+            "issued_token_type": ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
+            "token_type": "Bearer",
+            "access_boundary_session_key": session_key,
+        });
+        if let Some(expires_in) = expires_in {
+            value["expires_in"] = expires_in.into();
+        }
+        response(
+            http::StatusCode::OK,
+            serde_json::to_vec(&value).expect("response JSON must serialize"),
+        )
+    }
+
+    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
+        form_urlencoded::parse(&request.body).into_owned().collect()
+    }
+
+    fn output_token(credential: &Credential) -> &Token {
+        assert!(credential.service_account.is_none());
+        credential
+            .token
+            .as_ref()
+            .expect("granted credential must contain a token")
+    }
+
+    fn decrypt_restrictions(
+        credential: &Credential,
+    ) -> (String, [u8; AES_GCM_NONCE_BYTES], ClientSideAccessBoundary) {
+        let token = &output_token(credential).access_token;
+        let (intermediary, encoded_restrictions) = token
+            .split_once('.')
+            .expect("client-issued token must contain two parts");
+        let encrypted = URL_SAFE_NO_PAD
+            .decode(encoded_restrictions)
+            .expect("restrictions must be unpadded base64url");
+        let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
+            .expect("official session key must parse");
+        assert!(encrypted.starts_with(&key.output_prefix));
+        let nonce_offset = key.output_prefix.len();
+        let nonce: [u8; AES_GCM_NONCE_BYTES] = encrypted
+            [nonce_offset..nonce_offset + AES_GCM_NONCE_BYTES]
+            .try_into()
+            .expect("nonce must have the expected length");
+        let ciphertext = &encrypted[nonce_offset + AES_GCM_NONCE_BYTES..];
+        let plaintext = match key.key_value.len() {
+            16 => Aes128Gcm::new_from_slice(&key.key_value)
+                .expect("AES-128 key must initialize")
+                .decrypt(&Array(nonce), ciphertext),
+            32 => Aes256Gcm::new_from_slice(&key.key_value)
+                .expect("AES-256 key must initialize")
+                .decrypt(&Array(nonce), ciphertext),
+            _ => panic!("unexpected test key length"),
+        }
+        .expect("restrictions must decrypt");
+        let restrictions = ClientSideAccessBoundary::decode(plaintext.as_slice())
+            .expect("restrictions must be a CAB protobuf");
+        (intermediary.to_string(), nonce, restrictions)
+    }
+
+    fn encoded_tink_keyset(
+        key_id: u32,
+        status: i32,
+        output_prefix_type: i32,
+        type_url: &str,
+        material_type: i32,
+        version: u32,
+        key_value: Vec<u8>,
+    ) -> String {
+        let value = AesGcmKeyProto { version, key_value }.encode_to_vec();
+        STANDARD.encode(
+            TinkKeyset {
+                primary_key_id: key_id,
+                keys: vec![TinkKeysetKey {
+                    key_data: Some(TinkKeyData {
+                        type_url: type_url.to_string(),
+                        value,
+                        key_material_type: material_type,
+                    }),
+                    status,
+                    key_id,
+                    output_prefix_type,
+                }],
+            }
+            .encode_to_vec(),
+        )
+    }
+
+    #[test]
+    fn matches_google_auth_library_tink_aes_gcm_vector() {
+        let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
+            .expect("official session key must parse");
+        assert_eq!(
+            key.output_prefix,
+            [0x01, 0xa1, 0x61, 0x42, 0x76],
+            "Tink prefix must contain the official primary key id"
+        );
+        assert_eq!(
+            key.key_value.as_slice(),
+            &[
+                0xcc, 0x7c, 0xb3, 0x2b, 0xc6, 0x20, 0x61, 0xae, 0xe7, 0x2b, 0xeb, 0x76, 0xaf, 0xc8,
+                0xd1, 0x0f, 0x59, 0x58, 0x84, 0x75, 0xa2, 0xa2, 0x57, 0x16, 0x70, 0xc5, 0xc4, 0x7b,
+                0xb0, 0x5f, 0x84, 0x84,
+            ]
+        );
+
+        let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
+        let encrypted = key
+            .encrypt(b"restriction", &nonce)
+            .expect("vector must encrypt");
+        assert_eq!(
+            URL_SAFE_NO_PAD.encode(encrypted),
+            "AaFhQnYAAQIDBAUGBwgJCgvIc9p0EXthr8WYQl6sKvdE-kCKA-SIx7I0T1E"
+        );
+    }
+
+    #[test]
+    fn refresh_lock_registry_does_not_retain_sequential_partitions() {
+        let state = IntermediaryState::default();
+        let expires_at = timestamp("2030-01-01T01:00:00Z");
+        for index in 0..1024 {
+            let lease = state.refresh_lock(&IntermediaryCacheKey {
+                endpoint: STS_ENDPOINT,
+                source_authority: format!("source-{index}"),
+                source_expires_at: expires_at,
+            });
+            assert_eq!(state.refresh_lock_len(), 1);
+            drop(lease);
+            assert_eq!(state.refresh_lock_len(), 0);
+        }
+    }
+
+    #[tokio::test]
+    async fn sends_exact_official_intermediary_exchange_shape() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let source_expiry = timestamp("2030-01-01T02:00:00Z");
+        let http = MockHttpSend::new([success_response("intermediary-token", Some(3600))]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[0; AES_GCM_NONCE_BYTES]]);
+        let source = source_token("source+token/with=reserved", Some(source_expiry));
+
+        operation
+            .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
+            .await
+            .expect("client-side grant must succeed");
+
+        let requests = http.requests();
+        assert_eq!(requests.len(), 1);
+        let request = &requests[0];
+        assert_eq!(request.method, http::Method::POST);
+        assert_eq!(request.uri, STS_ENDPOINT);
+        assert_eq!(request.headers[ACCEPT], "application/json");
+        assert_eq!(
+            request.headers[CONTENT_TYPE],
+            "application/x-www-form-urlencoded"
+        );
+        assert!(!request.headers.contains_key(AUTHORIZATION));
+        assert_eq!(
+            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
+            concat!(
+                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
+                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3A",
+                "access_boundary_intermediary_token",
+                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
+                "&subject_token=source%2Btoken%2Fwith%3Dreserved"
+            )
+        );
+        let fields = form_fields(request);
+        assert_eq!(fields.len(), 4);
+        assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
+        assert_eq!(
+            fields["requested_token_type"],
+            ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
+        );
+        assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
+        assert_eq!(fields["subject_token"], "source+token/with=reserved");
+        assert!(!fields.contains_key("options"));
+        assert!(!fields.contains_key("audience"));
+        assert!(!fields.contains_key("scope"));
+    }
+
+    #[tokio::test]
+    async fn serializes_typed_prefix_grant_as_compiled_cel_protobuf() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
+            "example-bucket",
+            "customer \"雪\"/",
+            CredentialAccessBoundaryPermissions::OBJECT_VIEWER
+                | CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
+        );
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
+            .with_time(now)
+            .with_nonces([[7; AES_GCM_NONCE_BYTES]]);
+        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
+        let output = operation
+            .grant_credential(
+                &Context::new().with_http_send(http),
+                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
+                None,
+            )
+            .await
+            .expect("client-side grant must succeed");
+
+        let (intermediary, nonce, restrictions) = decrypt_restrictions(&output);
+        assert_eq!(intermediary, "intermediary");
+        assert_eq!(nonce, [7; AES_GCM_NONCE_BYTES]);
+        assert_eq!(restrictions.access_boundary_rules.len(), 1);
+        let rule = &restrictions.access_boundary_rules[0];
+        assert_eq!(
+            rule.available_resource,
+            "//storage.googleapis.com/projects/_/buckets/example-bucket"
+        );
+        assert_eq!(
+            rule.available_permissions,
+            [
+                "inRole:roles/storage.objectViewer",
+                "inRole:roles/storage.objectCreator",
+            ]
+        );
+
+        let root = rule
+            .compiled_availability_condition
+            .as_ref()
+            .expect("prefix rule must have a compiled condition");
+        let root_call = root.call_expr.as_ref().expect("root must be a call");
+        assert_eq!(root_call.function, "_||_");
+        assert!(root_call.target.is_none());
+        assert_eq!(root_call.args.len(), 2);
+
+        let resource_call = root_call.args[0]
+            .call_expr
+            .as_ref()
+            .expect("first branch must be a call");
+        assert_eq!(resource_call.function, "startsWith");
+        let resource_select = resource_call
+            .target
+            .as_deref()
+            .and_then(|expr| expr.select_expr.as_ref())
+            .expect("resource startsWith target must be a select");
+        assert_eq!(resource_select.field, "name");
+        assert_eq!(
+            resource_select
+                .operand
+                .as_deref()
+                .and_then(|expr| expr.ident_expr.as_ref())
+                .map(|ident| ident.name.as_str()),
+            Some("resource")
+        );
+        assert_eq!(
+            resource_call.args[0]
+                .const_expr
+                .as_ref()
+                .and_then(|constant| constant.string_value.as_deref()),
+            Some("projects/_/buckets/example-bucket/objects/customer \"雪\"/")
+        );
+
+        let list_call = root_call.args[1]
+            .call_expr
+            .as_ref()
+            .expect("second branch must be a call");
+        assert_eq!(list_call.function, "startsWith");
+        let get_attribute = list_call
+            .target
+            .as_deref()
+            .and_then(|expr| expr.call_expr.as_ref())
+            .expect("list startsWith target must call getAttribute");
+        assert_eq!(get_attribute.function, "getAttribute");
+        assert_eq!(
+            get_attribute
+                .target
+                .as_deref()
+                .and_then(|expr| expr.ident_expr.as_ref())
+                .map(|ident| ident.name.as_str()),
+            Some("api")
+        );
+        assert_eq!(
+            get_attribute.args[0]
+                .const_expr
+                .as_ref()
+                .and_then(|constant| constant.string_value.as_deref()),
+            Some("storage.googleapis.com/objectListPrefix")
+        );
+        assert_eq!(
+            get_attribute.args[1]
+                .const_expr
+                .as_ref()
+                .and_then(|constant| constant.string_value.as_deref()),
+            Some("")
+        );
+        assert_eq!(
+            list_call.args[0]
+                .const_expr
+                .as_ref()
+                .and_then(|constant| constant.string_value.as_deref()),
+            Some("customer \"雪\"/")
+        );
+    }
+
+    #[tokio::test]
+    async fn bucket_wide_and_multiple_rules_preserve_union_order() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let grant = viewer_bucket_grant().with_object_prefix_rule(
+            "second-bucket",
+            "tenant/",
+            CredentialAccessBoundaryPermissions::OBJECT_USER,
+        );
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
+            .with_time(now)
+            .with_nonces([[9; AES_GCM_NONCE_BYTES]]);
+        let output = operation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "intermediary",
+                    Some(3600),
+                )])),
+                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
+                None,
+            )
+            .await
+            .expect("client-side grant must succeed");
+
+        let (_, _, restrictions) = decrypt_restrictions(&output);
+        assert_eq!(restrictions.access_boundary_rules.len(), 2);
+        assert_eq!(
+            restrictions.access_boundary_rules[0].available_resource,
+            "//storage.googleapis.com/projects/_/buckets/example-bucket"
+        );
+        assert!(
+            restrictions.access_boundary_rules[0]
+                .compiled_availability_condition
+                .is_none()
+        );
+        assert_eq!(
+            restrictions.access_boundary_rules[1].available_resource,
+            "//storage.googleapis.com/projects/_/buckets/second-bucket"
+        );
+        assert!(
+            restrictions.access_boundary_rules[1]
+                .compiled_availability_condition
+                .is_some()
+        );
+    }
+
+    #[tokio::test]
+    async fn rejects_invalid_grant_before_io() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(
+            CredentialAccessBoundaryGrant::for_object_prefix(
+                "example-bucket",
+                "",
+                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+            ),
+        )
+        .with_time(now);
+        let err = operation
+            .grant_credential(
+                &Context::new().with_http_send(http.clone()),
+                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
+                None,
+            )
+            .await
+            .expect_err("invalid grant must fail");
+
+        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
+        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
+    }
+
+    #[tokio::test]
+    async fn rejects_incompatible_sources_and_lifetimes_before_io() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([]);
+        let ctx = Context::new().with_http_send(http.clone());
+        let operation =
+            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
+        let valid_token = Token {
+            access_token: "source".to_string(),
+            expires_at: Some(now + Duration::from_secs(2 * 60 * 60)),
+        };
+        let invalid_sources = [
+            Credential::with_service_account(ServiceAccount {
+                private_key: "private".to_string(),
+                client_email: "service@example.com".to_string(),
+            }),
+            Credential {
+                service_account: Some(ServiceAccount {
+                    private_key: "private".to_string(),
+                    client_email: "service@example.com".to_string(),
+                }),
+                token: Some(valid_token),
+            },
+            source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
+            source_token("source", None),
+            source_token("source", Some(now + DEFAULT_MINIMUM_TOKEN_LIFETIME)),
+        ];
+        for source in invalid_sources {
+            let err = operation
+                .grant_credential(&ctx, &source, None)
+                .await
+                .expect_err("incompatible source must fail");
+            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+        }
+
+        let valid_source = source_token("source", Some(now + Duration::from_secs(13 * 60 * 60)));
+        for lifetime in [
+            Duration::ZERO,
+            MAX_ACCESS_TOKEN_LIFETIME,
+            MAX_ACCESS_TOKEN_LIFETIME + Duration::from_secs(1),
+        ] {
+            let err = operation
+                .grant_credential(&ctx, &valid_source, Some(lifetime))
+                .await
+                .expect_err("invalid minimum lifetime must fail");
+            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
+        }
+        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
+    }
+
+    #[test]
+    fn validates_exact_minimum_lifetime_boundary_after_rounding() {
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant());
+        assert_eq!(
+            operation
+                .effective_minimum_lifetime(Some(MAX_MINIMUM_TOKEN_LIFETIME))
+                .expect("the largest feasible whole-second lifetime must be accepted"),
+            MAX_MINIMUM_TOKEN_LIFETIME
+        );
+
+        for lifetime in [
+            MAX_MINIMUM_TOKEN_LIFETIME + Duration::from_nanos(1),
+            MAX_ACCESS_TOKEN_LIFETIME,
+            Duration::MAX,
+        ] {
+            let err = operation
+                .effective_minimum_lifetime(Some(lifetime))
+                .expect_err("a lifetime that rounds to twelve hours must fail");
+            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
+        }
+    }
+
+    #[tokio::test]
+    async fn accepts_maximum_intermediary_and_minimum_lifetime_boundaries() {
+        let request_time = timestamp("2030-01-01T00:00:00Z");
+        let response_time = timestamp("2030-01-01T00:00:01Z");
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(request_time)
+            .with_time_after_request(response_time)
+            .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
+        let output = operation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "intermediary",
+                    Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
+                )])),
+                &source_token(
+                    "source",
+                    Some(request_time + Duration::from_secs(13 * 60 * 60)),
+                ),
+                Some(MAX_MINIMUM_TOKEN_LIFETIME),
+            )
+            .await
+            .expect("maximum feasible lifetime must succeed");
+
+        assert_eq!(
+            output_token(&output).expires_at,
+            Some(timestamp("2030-01-01T12:00:01Z"))
+        );
+    }
+
+    #[tokio::test]
+    async fn anchors_clamps_and_requires_intermediary_expiration() {
+        let request_time = timestamp("2030-01-01T00:00:00Z");
+        let response_time = timestamp("2030-01-01T00:00:02Z");
+        let source_expiry = timestamp("2030-01-01T02:00:00Z");
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(request_time)
+            .with_time_after_request(response_time)
+            .with_nonces([
+                [1; AES_GCM_NONCE_BYTES],
+                [2; AES_GCM_NONCE_BYTES],
+                [3; AES_GCM_NONCE_BYTES],
+            ]);
+        let source = source_token("source", Some(source_expiry));
+
+        let explicit = operation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "explicit",
+                    Some(3600),
+                )])),
+                &source,
+                None,
+            )
+            .await
+            .expect("explicit expiration must succeed");
+        assert_eq!(
+            output_token(&explicit).expires_at,
+            Some(timestamp("2030-01-01T01:00:02Z"))
+        );
+
+        let missing_expiration_operation =
+            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+                .with_time(request_time)
+                .with_time_after_request(response_time);
+        let err = missing_expiration_operation
+            .grant_credential(
+                &Context::new()
+                    .with_http_send(MockHttpSend::new([success_response("inherited", None)])),
+                &source,
+                None,
+            )
+            .await
+            .expect_err("client-issued CAB requires a service-account STS expiration");
+        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+
+        let clamped_operation =
+            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+                .with_time(request_time)
+                .with_time_after_request(response_time)
+                .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
+        let clamped = clamped_operation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "clamped",
+                    Some(3 * 60 * 60),
+                )])),
+                &source,
+                None,
+            )
+            .await
+            .expect("STS expiration must clamp to source expiration");
+        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
+    }
+
+    #[tokio::test]
+    async fn rejects_malformed_expiration_and_sts_responses() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+        let malformed = [
+            success_response("zero", Some(0)),
+            success_response("too-long", Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1)),
+            response(
+                http::StatusCode::OK,
+                format!(
+                    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}"}}"#
+                ),
+            ),
+            response(
+                http::StatusCode::OK,
+                br#"{"access_token":"missing-fields"}"#.as_slice(),
+            ),
+            success_response_with_key("bad-key", "not base64!", Some(3600)),
+        ];
+
+        for response in malformed {
+            let http = MockHttpSend::new([response]);
+            let err = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+                .with_time(now)
+                .grant_credential(&Context::new().with_http_send(http), &source, None)
+                .await
+                .expect_err("malformed response must fail");
+            assert_eq!(err.kind(), ErrorKind::Unexpected);
+            let debug = format!("{err:?}");
+            assert!(!debug.contains("bad-key"));
+            assert!(!debug.contains(GOOGLE_AUTH_LIBRARY_SESSION_KEY));
+        }
+    }
+
+    #[tokio::test]
+    async fn validates_tink_keyset_shape_and_algorithm() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+        let keys = [
+            encoded_tink_keyset(
+                7,
+                0,
+                TINK_PREFIX,
+                AES_GCM_KEY_TYPE_URL,
+                TINK_KEY_MATERIAL_SYMMETRIC,
+                0,
+                vec![1; 32],
+            ),
+            encoded_tink_keyset(
+                7,
+                TINK_KEY_STATUS_ENABLED,
+                0,
+                AES_GCM_KEY_TYPE_URL,
+                TINK_KEY_MATERIAL_SYMMETRIC,
+                0,
+                vec![1; 32],
+            ),
+            encoded_tink_keyset(
+                7,
+                TINK_KEY_STATUS_ENABLED,
+                TINK_PREFIX,
+                "type.googleapis.com/google.crypto.tink.ChaCha20Poly1305Key",
+                TINK_KEY_MATERIAL_SYMMETRIC,
+                0,
+                vec![1; 32],
+            ),
+            encoded_tink_keyset(
+                7,
+                TINK_KEY_STATUS_ENABLED,
+                TINK_PREFIX,
+                AES_GCM_KEY_TYPE_URL,
+                0,
+                0,
+                vec![1; 32],
+            ),
+            encoded_tink_keyset(
+                7,
+                TINK_KEY_STATUS_ENABLED,
+                TINK_PREFIX,
+                AES_GCM_KEY_TYPE_URL,
+                TINK_KEY_MATERIAL_SYMMETRIC,
+                1,
+                vec![1; 32],
+            ),
+            encoded_tink_keyset(
+                7,
+                TINK_KEY_STATUS_ENABLED,
+                TINK_PREFIX,
+                AES_GCM_KEY_TYPE_URL,
+                TINK_KEY_MATERIAL_SYMMETRIC,
+                0,
+                vec![1; 24],
+            ),
+        ];
+
+        for key in keys {
+            let err =
+                ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+                    .with_time(now)
+                    .grant_credential(
+                        &Context::new().with_http_send(MockHttpSend::new([
+                            success_response_with_key("intermediary", &key, Some(3600)),
+                        ])),
+                        &source,
+                        None,
+                    )
+                    .await
+                    .expect_err("invalid keyset must fail");
+            assert_eq!(err.kind(), ErrorKind::Unexpected);
+            assert!(!format!("{err:?}").contains(&key));
+        }
+    }
+
+    #[tokio::test]
+    async fn checks_post_io_and_post_generation_validity() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let source_expiry = now + Duration::from_secs(60 * 60);
+
+        let post_io = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_time_after_request(now + Duration::from_secs(31 * 60));
+        let err = post_io
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "intermediary",
+                    Some(3600),
+                )])),
+                &source_token("source", Some(source_expiry)),
+                None,
+            )
+            .await
+            .expect_err("intermediary must cover the minimum after I/O");
+        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+
+        let post_generation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_time_after_generation(now + Duration::from_secs(30 * 60))
+            .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
+        let err = post_generation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "intermediary",
+                    Some(60 * 60),
+                )])),
+                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
+                None,
+            )
+            .await
+            .expect_err("output must cover the minimum after local generation");
+        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+    }
+
+    #[tokio::test]
+    async fn reuses_intermediary_but_never_caches_outputs() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+        let ctx = Context::new().with_http_send(http.clone());
+
+        let first = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("first grant must succeed");
+        let second = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("second grant must succeed");
+
+        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
+        assert_eq!(operation.cache_len().await, 1);
+        assert_ne!(
+            output_token(&first).access_token,
+            output_token(&second).access_token
+        );
+        assert_eq!(
+            output_token(&first).expires_at,
+            output_token(&second).expires_at
+        );
+        assert_eq!(decrypt_restrictions(&first).1, [1; AES_GCM_NONCE_BYTES]);
+        assert_eq!(decrypt_restrictions(&second).1, [2; AES_GCM_NONCE_BYTES]);
+    }
+
+    #[tokio::test]
+    async fn refreshes_only_when_cached_intermediary_cannot_cover_minimum() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([
+            success_response("intermediary-1", Some(3600)),
+            success_response("intermediary-2", Some(3600)),
+        ]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([
+                [1; AES_GCM_NONCE_BYTES],
+                [2; AES_GCM_NONCE_BYTES],
+                [3; AES_GCM_NONCE_BYTES],
+            ]);
+        let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
+        let ctx = Context::new().with_http_send(http.clone());
+
+        let first = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("initial grant must succeed");
+        let still_fresh = operation
+            .clone()
+            .with_time(now + Duration::from_secs(20 * 60))
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("fresh intermediary must be reused");
+        let refreshed = operation
+            .clone()
+            .with_time(now + Duration::from_secs(31 * 60))
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("near-expiry intermediary must refresh");
+
+        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
+        assert_eq!(decrypt_restrictions(&first).0, "intermediary-1");
+        assert_eq!(decrypt_restrictions(&still_fresh).0, "intermediary-1");
+        assert_eq!(decrypt_restrictions(&refreshed).0, "intermediary-2");
+    }
+
+    #[tokio::test]
+    async fn failed_refresh_never_returns_insufficient_cached_output() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([
+            success_response("intermediary-1", Some(3600)),
+            response(
+                http::StatusCode::SERVICE_UNAVAILABLE,
+                r#"{"error":"backend_error","error_description":"do not return stale material"}"#,
+            ),
+        ]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[1; AES_GCM_NONCE_BYTES]]);
+        let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
+        let ctx = Context::new().with_http_send(http.clone());
+        operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("initial grant must succeed");
+
+        let err = operation
+            .clone()
+            .with_time(now + Duration::from_secs(31 * 60))
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect_err("failed refresh must not return cached output");
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+        assert!(err.is_retryable());
+        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
+        assert!(!format!("{err:?}").contains("do not return stale material"));
+    }
+
+    #[tokio::test]
+    async fn partitions_and_bounds_intermediary_cache() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let responses = (0..INTERMEDIARY_CACHE_CAPACITY + 2)
+            .map(|index| success_response(&format!("intermediary-{index}"), Some(3600)))
+            .collect::<Vec<_>>();
+        let http = MockHttpSend::new(responses);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces(
+                (0..INTERMEDIARY_CACHE_CAPACITY + 2)
+                    .map(|index| [index as u8; AES_GCM_NONCE_BYTES]),
+            );
+        let ctx = Context::new().with_http_send(http.clone());
+        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
+
+        for index in 0..=INTERMEDIARY_CACHE_CAPACITY {
+            operation
+                .grant_credential(
+                    &ctx,
+                    &source_token(&format!("source-{index}"), Some(source_expiry)),
+                    None,
+                )
+                .await
+                .expect("partitioned grant must succeed");
+        }
+        assert_eq!(
+            http.calls.load(Ordering::SeqCst),
+            INTERMEDIARY_CACHE_CAPACITY + 1
+        );
+        assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
+
+        operation
+            .grant_credential(&ctx, &source_token("source-0", Some(source_expiry)), None)
+            .await
+            .expect("oldest authority must be fetched after eviction");
+        assert_eq!(
+            http.calls.load(Ordering::SeqCst),
+            INTERMEDIARY_CACHE_CAPACITY + 2
+        );
+        assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
+
+        let a = IntermediaryCacheKey {
+            endpoint: STS_ENDPOINT,
+            source_authority: "authority".to_string(),
+            source_expires_at: source_expiry,
+        };
+        let b = IntermediaryCacheKey {
+            endpoint: "https://sts.example.invalid/v1/token",
+            source_authority: "authority".to_string(),
+            source_expires_at: source_expiry,
+        };
+        assert!(a != b, "endpoint identity must partition cache keys");
+    }
+
+    #[tokio::test]
+    async fn source_authority_partition_reuses_each_matching_entry() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([
+            success_response("intermediary-a", Some(3600)),
+            success_response("intermediary-b", Some(3600)),
+        ]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([
+                [1; AES_GCM_NONCE_BYTES],
+                [2; AES_GCM_NONCE_BYTES],
+                [3; AES_GCM_NONCE_BYTES],
+            ]);
+        let ctx = Context::new().with_http_send(http.clone());
+        let expiry = now + Duration::from_secs(2 * 60 * 60);
+        let source_a = source_token("source-a", Some(expiry));
+        let source_b = source_token("source-b", Some(expiry));
+
+        let first_a = operation
+            .grant_credential(&ctx, &source_a, None)
+            .await
+            .expect("source A must succeed");
+        let output_b = operation
+            .grant_credential(&ctx, &source_b, None)
+            .await
+            .expect("source B must succeed");
+        let second_a = operation
+            .grant_credential(&ctx, &source_a, None)
+            .await
+            .expect("source A cache entry must be reused");
+
+        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
+        assert_eq!(operation.cache_len().await, 2);
+        assert_eq!(decrypt_restrictions(&first_a).0, "intermediary-a");
+        assert_eq!(decrypt_restrictions(&output_b).0, "intermediary-b");
+        assert_eq!(decrypt_restrictions(&second_a).0, "intermediary-a");
+    }
+
+    #[tokio::test]
+    async fn unrelated_source_partitions_refresh_independently() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let (http, gate) = MockHttpSend::gated([
+            success_response("intermediary-a", Some(3600)),
+            success_response("intermediary-b", Some(3600)),
+        ]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
+        let ctx = Context::new().with_http_send(http.clone());
+        let expiry = now + Duration::from_secs(2 * 60 * 60);
+
+        let first = tokio::spawn({
+            let operation = operation.clone();
+            let ctx = ctx.clone();
+            async move {
+                operation
+                    .grant_credential(&ctx, &source_token("source-a", Some(expiry)), None)
+                    .await
+            }
+        });
+        gate.wait_started().await;
+
+        let second = tokio::spawn({
+            let operation = operation.clone();
+            let ctx = ctx.clone();
+            async move {
+                operation
+                    .grant_credential(&ctx, &source_token("source-b", Some(expiry)), None)
+                    .await
+            }
+        });
+        let second_started =
+            tokio::time::timeout(Duration::from_secs(1), gate.wait_started()).await;
+        gate.release_one();
+        gate.release_one();
+        assert!(
+            second_started.is_ok(),
+            "an unrelated source partition must not wait for the first STS exchange"
+        );
+
+        first
+            .await
+            .expect("first grant task must not panic")
+            .expect("first source partition must succeed");
+        second
+            .await
+            .expect("second grant task must not panic")
+            .expect("second source partition must succeed");
+        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
+        assert_eq!(operation.cache_len().await, 2);
+        assert_eq!(operation.refresh_lock_len(), 0);
+    }
+
+    #[tokio::test]
+    async fn concurrent_grants_share_successful_intermediary_refresh() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces((0..8).map(|index| [index; AES_GCM_NONCE_BYTES]));
+        let ctx = Context::new().with_http_send(http.clone());
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+        let mut tasks = Vec::new();
+        for _ in 0..8 {
+            let operation = operation.clone();
+            let ctx = ctx.clone();
+            let source = source.clone();
+            tasks.push(tokio::spawn(async move {
+                operation.grant_credential(&ctx, &source, None).await
+            }));
+        }
+
+        gate.wait_started().await;
+        for _ in 0..4 {
+            tokio::task::yield_now().await;
+        }
+        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
+        gate.release_one();
+
+        let mut tokens = HashSet::new();
+        for task in tasks {
+            let output = task
+                .await
+                .expect("grant task must not panic")
+                .expect("concurrent grant must succeed");
+            tokens.insert(output_token(&output).access_token.clone());
+        }
+        assert_eq!(tokens.len(), 8);
+        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
+        assert_eq!(operation.cache_len().await, 1);
+        assert_eq!(operation.refresh_lock_len(), 0);
+    }
+
+    #[tokio::test]
+    async fn waiter_retries_after_serialized_refresh_failure() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let (http, gate) = MockHttpSend::gated([
+            response(
+                http::StatusCode::SERVICE_UNAVAILABLE,
+                r#"{"error":"backend_error"}"#,
+            ),
+            success_response("intermediary", Some(3600)),
+        ]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
+        let ctx = Context::new().with_http_send(http.clone());
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+
+        let leader = tokio::spawn({
+            let operation = operation.clone();
+            let ctx = ctx.clone();
+            let source = source.clone();
+            async move { operation.grant_credential(&ctx, &source, None).await }
+        });
+        gate.wait_started().await;
+        let waiter = tokio::spawn({
+            let operation = operation.clone();
+            let ctx = ctx.clone();
+            async move { operation.grant_credential(&ctx, &source, None).await }
+        });
+        for _ in 0..4 {
+            tokio::task::yield_now().await;
+        }
+        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
+
+        gate.release_one();
+        let err = leader
+            .await
+            .expect("leader task must not panic")
+            .expect_err("leader refresh must fail");
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+        assert!(err.is_retryable());
+
+        gate.wait_started().await;
+        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
+        gate.release_one();
+        let output = waiter
+            .await
+            .expect("waiter task must not panic")
+            .expect("waiter must retry and succeed");
+        assert_eq!(decrypt_restrictions(&output).0, "intermediary");
+        assert_eq!(operation.cache_len().await, 1);
+        assert_eq!(operation.refresh_lock_len(), 0);
+    }
+
+    #[tokio::test]
+    async fn cancelled_refresh_releases_partition_lock_without_caching_partial_state() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[5; AES_GCM_NONCE_BYTES]]);
+        let ctx = Context::new().with_http_send(http.clone());
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+
+        let cancelled = {
+            let operation = operation.clone();
+            let ctx = ctx.clone();
+            let source = source.clone();
+            tokio::spawn(async move { operation.grant_credential(&ctx, &source, None).await })
+        };
+        gate.wait_started().await;
+        cancelled.abort();
+        assert!(
+            cancelled
+                .await
+                .expect_err("task must be cancelled")
+                .is_cancelled()
+        );
+        assert_eq!(operation.cache_len().await, 0);
+        assert_eq!(operation.refresh_lock_len(), 0);
+
+        gate.release_one();
+        let output = tokio::time::timeout(
+            Duration::from_secs(2),
+            operation.grant_credential(&ctx, &source, None),
+        )
+        .await
+        .expect("retry must not deadlock")
+        .expect("retry after cancellation must succeed");
+        assert_eq!(decrypt_restrictions(&output).0, "intermediary");
+        assert_eq!(http.calls.load(Ordering::SeqCst), 2);
+        assert_eq!(operation.cache_len().await, 1);
+        assert_eq!(operation.refresh_lock_len(), 0);
+    }
+
+    #[tokio::test]
+    async fn with_grant_shares_intermediary_for_distinct_authorization() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
+        let bucket = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
+        let prefix = bucket
+            .clone()
+            .with_grant(CredentialAccessBoundaryGrant::for_object_prefix(
+                "example-bucket",
+                "tenant/",
+                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+            ));
+        let ctx = Context::new().with_http_send(http.clone());
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+
+        let bucket_output = bucket
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("bucket grant must succeed");
+        let prefix_output = prefix
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("prefix grant must succeed");
+        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
+        assert!(
+            decrypt_restrictions(&bucket_output).2.access_boundary_rules[0]
+                .compiled_availability_condition
+                .is_none()
+        );
+        assert!(
+            decrypt_restrictions(&prefix_output).2.access_boundary_rules[0]
+                .compiled_availability_condition
+                .is_some()
+        );
+    }
+
+    #[tokio::test]
+    async fn granter_lifecycle_caches_source_and_intermediary_but_not_outputs() {
+        let now = Timestamp::now();
+        let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
+        let (provider, provider_calls) = FixedCredentialProvider::new(source);
+        let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
+        let granter = Granter::new(
+            Context::new().with_http_send(http.clone()),
+            provider,
+            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+                .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]),
+        );
+
+        let first = granter.grant(None).await.expect("first grant must succeed");
+        let second = granter
+            .grant(None)
+            .await
+            .expect("second grant must succeed");
+        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
+        assert_eq!(http.calls.load(Ordering::SeqCst), 1);
+        assert_ne!(
+            output_token(&first).access_token,
+            output_token(&second).access_token
+        );
+    }
+
+    #[tokio::test]
+    async fn generated_token_is_consumed_by_existing_google_signer() {
+        let now = Timestamp::now();
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_nonces([[8; AES_GCM_NONCE_BYTES]]);
+        let output = operation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "intermediary",
+                    Some(3600),
+                )])),
+                &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
+                None,
+            )
+            .await
+            .expect("client-side grant must succeed");
+        let expected = format!("Bearer {}", output_token(&output).access_token);
+        let (provider, _) = FixedCredentialProvider::new(output);
+        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
+        let mut parts =
+            http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+
+        signer
+            .sign(&mut parts, None)
+            .await
+            .expect("existing signer must consume client-issued CAB token");
+        assert_eq!(parts.headers[AUTHORIZATION], expected);
+        assert!(parts.headers[AUTHORIZATION].is_sensitive());
+    }
+
+    #[tokio::test]
+    async fn sts_and_transport_errors_are_semantic_and_redacted() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let source = source_token(
+            "source-secret",
+            Some(now + Duration::from_secs(2 * 60 * 60)),
+        );
+        let operation =
+            ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
+        let err = operation
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([response(
+                    http::StatusCode::BAD_REQUEST,
+                    r#"{"error":"invalid_grant","error_description":"source-secret raw-response-secret"}"#,
+                )])),
+                &source,
+                None,
+            )
+            .await
+            .expect_err("STS error must fail");
+        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+        assert!(format!("{err:?}").contains("invalid_grant"));
+        assert!(!format!("{err:?}").contains("source-secret"));
+        assert!(!format!("{err:?}").contains("raw-response-secret"));
+
+        let err = operation
+            .grant_credential(
+                &Context::new().with_http_send(SecretTransportError),
+                &source,
+                None,
+            )
+            .await
+            .expect_err("transport error must fail");
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+        assert!(err.is_retryable());
+        assert!(!format!("{err:?}").contains("source-secret"));
+        assert!(!format!("{err:?}").contains("transport retained"));
+    }
+
+    #[tokio::test]
+    async fn debug_redacts_grant_request_and_all_credential_material() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
+            "sensitive-bucket",
+            "sensitive/prefix",
+            CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
+        );
+        let operation = ClientSideCredentialAccessBoundaryGranter::new(grant.clone())
+            .with_time(now)
+            .with_nonces([[6; AES_GCM_NONCE_BYTES]]);
+        let source = source_token(
+            "source-secret",
+            Some(now + Duration::from_secs(2 * 60 * 60)),
+        );
+        let http = MockHttpSend::new([success_response("intermediary-secret", Some(3600))]);
+        let output = operation
+            .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
+            .await
+            .expect("client-side grant must succeed");
+        let request = http
+            .requests()
+            .into_iter()
+            .next()
+            .expect("request must be captured");
+        let key =
+            TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY).expect("official key must parse");
+
+        for (debug, secret) in [
+            (format!("{grant:?}"), "sensitive-bucket"),
+            (format!("{grant:?}"), "sensitive/prefix"),
+            (format!("{operation:?}"), "sensitive-bucket"),
+            (format!("{source:?}"), "source-secret"),
+            (format!("{request:?}"), "source-secret"),
+            (format!("{output:?}"), "intermediary-secret"),
+            (format!("{key:?}"), "cc7c"),
+            (
+                format!("{:?}", CredentialAccessBoundaryPermissions::OBJECT_ADMIN),
+                "storage.objectAdmin",
+            ),
+        ] {
+            assert!(!debug.contains(secret), "{debug}");
+        }
+        assert_eq!(
+            format!("{operation:?}"),
+            "ClientSideCredentialAccessBoundaryGranter { .. }"
+        );
+    }
+}
diff --git a/services/google/src/credential_access_boundary/server_side.rs b/services/google/src/credential_access_boundary/server_side.rs
new file mode 100644
index 0000000..aed87d5
--- /dev/null
+++ b/services/google/src/credential_access_boundary/server_side.rs
@@ -0,0 +1,969 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::{self, Debug};
+use std::time::Duration;
+
+use form_urlencoded::Serializer;
+use http::header::{ACCEPT, CONTENT_TYPE};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, GrantCredential, Result, SigningCredential};
+use serde::Deserialize;
+
+use super::CredentialAccessBoundaryGrant;
+use super::sts::{
+    ACCESS_TOKEN_TYPE, MAX_ACCESS_TOKEN_LIFETIME, STS_ENDPOINT, TOKEN_EXCHANGE_GRANT_TYPE,
+    checked_expiration, sts_error,
+};
+use crate::constants::TOKEN_OPERATION_HEADROOM;
+use crate::{Credential, Token};
+
+const TOKEN_EXCHANGE_HEADROOM: Duration = Duration::from_secs(10);
+
+/// Exchanges a Google OAuth access token for a server-issued CAB token.
+///
+/// The bound grant is stable configuration. Construct another granter (or use
+/// [`ServerSideCredentialAccessBoundaryGranter::with_grant`]) for a different
+/// authorization decision. The server-side CAB exchange does not accept a
+/// requested lifetime, so [`reqsign_core::Granter::grant`] must be called with
+/// `None`.
+///
+/// The source must be a token-only [`Credential`] containing a Google-issued
+/// OAuth access token with a known absolute expiration and the
+/// `https://www.googleapis.com/auth/cloud-platform` scope. Server-issued CAB
+/// tokens support user and service-account principals. STS rejects tokens that
+/// already carry security attributes; the opaque token string does not expose
+/// enough information to detect its principal, scope, or existing attributes
+/// locally. Credentials with unknown expiration or an attached service account
+/// are rejected before STS I/O.
+///
+/// The returned [`Credential`] is token-only and can be consumed directly by
+/// the existing Google [`crate::RequestSigner`]. Every grant performs a new STS
+/// exchange; the service layer does not cache granted outputs.
+///
+/// # Example
+///
+/// ```no_run
+/// use std::time::Duration;
+///
+/// use reqsign_core::{Context, Granter, time::Timestamp};
+/// use reqsign_google::{
+///     CredentialAccessBoundaryGrant, CredentialAccessBoundaryPermissions,
+///     ServerSideCredentialAccessBoundaryGranter, TokenCredentialProvider,
+/// };
+///
+/// # async fn example() -> reqsign_core::Result<()> {
+/// let source = TokenCredentialProvider::new("source-oauth-token")
+///     .with_expires_at(Timestamp::now() + Duration::from_secs(3600));
+/// let grant = CredentialAccessBoundaryGrant::for_object_prefix(
+///     "example-bucket",
+///     "customer-a/",
+///     CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+/// );
+/// // Supply a Context configured with an HttpSend implementation.
+/// let context = Context::new();
+/// let credential = Granter::new(
+///     context,
+///     source,
+///     ServerSideCredentialAccessBoundaryGranter::new(grant),
+/// )
+/// .grant(None)
+/// .await?;
+/// # let _ = credential;
+/// # Ok(())
+/// # }
+/// ```
+#[derive(Clone)]
+pub struct ServerSideCredentialAccessBoundaryGranter {
+    grant: CredentialAccessBoundaryGrant,
+    #[cfg(test)]
+    now: Option<Timestamp>,
+    #[cfg(test)]
+    time_after_request: Option<Timestamp>,
+}
+
+impl Debug for ServerSideCredentialAccessBoundaryGranter {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ServerSideCredentialAccessBoundaryGranter")
+            .finish_non_exhaustive()
+    }
+}
+
+impl ServerSideCredentialAccessBoundaryGranter {
+    /// Create a server-side granter for a bound Credential Access Boundary.
+    pub fn new(grant: CredentialAccessBoundaryGrant) -> Self {
+        Self {
+            grant,
+            #[cfg(test)]
+            now: None,
+            #[cfg(test)]
+            time_after_request: None,
+        }
+    }
+
+    /// Replace the bound grant.
+    pub fn with_grant(mut self, grant: CredentialAccessBoundaryGrant) -> Self {
+        self.grant = grant;
+        self
+    }
+
+    fn now(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(now) = self.now {
+            return now;
+        }
+        Timestamp::now()
+    }
+
+    fn time_after_request(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(now) = self.time_after_request {
+            return now;
+        }
+        #[cfg(test)]
+        if let Some(now) = self.now {
+            return now;
+        }
+        Timestamp::now()
+    }
+
+    #[cfg(test)]
+    fn with_time(mut self, now: Timestamp) -> Self {
+        self.now = Some(now);
+        self.time_after_request = Some(now);
+        self
+    }
+
+    #[cfg(test)]
+    fn with_time_after_request(mut self, now: Timestamp) -> Self {
+        self.time_after_request = Some(now);
+        self
+    }
+
+    fn source_token<'a>(
+        &self,
+        credential: &'a Credential,
+        required_until: Timestamp,
+    ) -> Result<&'a Token> {
+        if credential.service_account.is_some() {
+            return Err(Error::credential_invalid(
+                "server-side credential access boundary exchange requires a token-only source credential",
+            ));
+        }
+        let token = credential.token.as_ref().ok_or_else(|| {
+            Error::credential_invalid(
+                "server-side credential access boundary exchange requires an OAuth access token",
+            )
+        })?;
+        if token.access_token.is_empty() {
+            return Err(Error::credential_invalid(
+                "server-side credential access boundary source access token is empty",
+            ));
+        }
+        if token.expires_at.is_none() {
+            return Err(Error::credential_invalid(
+                "server-side credential access boundary source token expiration is required",
+            ));
+        }
+        if !token.is_valid_at(required_until) {
+            return Err(Error::credential_invalid(
+                "source OAuth access token expires before the server-side CAB exchange can complete",
+            ));
+        }
+        Ok(token)
+    }
+
+    fn build_request(
+        &self,
+        source_token: &str,
+        options: &str,
+    ) -> Result<http::Request<bytes::Bytes>> {
+        let body = Serializer::new(String::new())
+            .append_pair("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
+            .append_pair("requested_token_type", ACCESS_TOKEN_TYPE)
+            .append_pair("subject_token_type", ACCESS_TOKEN_TYPE)
+            .append_pair("subject_token", source_token)
+            .append_pair("options", options)
+            .finish();
+
+        http::Request::builder()
+            .method(http::Method::POST)
+            .uri(STS_ENDPOINT)
+            .header(ACCEPT, "application/json")
+            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
+            .body(body.into_bytes().into())
+            .map_err(|err| {
+                Error::unexpected("failed to build server-side CAB request").with_source(err)
+            })
+    }
+
+    fn parse_response(
+        &self,
+        response: http::Response<bytes::Bytes>,
+        source: &Token,
+        response_time: Timestamp,
+    ) -> Result<Credential> {
+        if response.status() != http::StatusCode::OK {
+            return Err(sts_error(response.status(), response.body()));
+        }
+
+        let token_response: StsTokenResponse = serde_json::from_slice(response.body())
+            .map_err(|_| Error::unexpected("failed to parse server-side CAB STS response"))?;
+        if token_response.access_token.is_empty()
+            || token_response.issued_token_type != ACCESS_TOKEN_TYPE
+            || token_response.token_type != "Bearer"
+        {
+            return Err(Error::unexpected(
+                "server-side CAB STS response is malformed",
+            ));
+        }
+
+        let source_expiration = source.expires_at.ok_or_else(|| {
+            Error::credential_invalid(
+                "server-side credential access boundary source token expiration is required",
+            )
+        })?;
+        if source_expiration <= response_time {
+            return Err(Error::credential_invalid(
+                "source OAuth access token expired during the server-side CAB exchange",
+            ));
+        }
+
+        let response_expiration = token_response
+            .expires_in
+            .map(|expires_in| {
+                let expires_in = Duration::from_secs(expires_in);
+                if expires_in.is_zero() || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
+                    return Err(Error::unexpected(
+                        "server-side CAB STS expiration is invalid",
+                    ));
+                }
+                checked_expiration(response_time, expires_in)
+            })
+            .transpose()?;
+        let expires_at = response_expiration
+            .map(|response| response.min(source_expiration))
+            .unwrap_or(source_expiration);
+        if expires_at <= response_time {
+            return Err(Error::unexpected(
+                "server-side CAB STS token is already expired",
+            ));
+        }
+
+        let credential = Credential::with_token(Token {
+            access_token: token_response.access_token,
+            expires_at: Some(expires_at),
+        });
+        let required_until = checked_expiration(response_time, TOKEN_OPERATION_HEADROOM)?;
+        if !credential.is_valid_at(required_until) {
+            return Err(Error::credential_invalid(
+                "server-issued CAB token is not valid long enough for Google signing",
+            ));
+        }
+        Ok(credential)
+    }
+}
+
+impl GrantCredential for ServerSideCredentialAccessBoundaryGranter {
+    type Credential = Credential;
+
+    fn required_valid_until(
+        &self,
+        _credential: &Self::Credential,
+        _expires_in: Option<Duration>,
+    ) -> Timestamp {
+        self.now() + TOKEN_EXCHANGE_HEADROOM + TOKEN_OPERATION_HEADROOM
+    }
+
+    async fn grant_credential(
+        &self,
+        ctx: &Context,
+        credential: &Self::Credential,
+        expires_in: Option<Duration>,
+    ) -> Result<Self::Credential> {
+        if expires_in.is_some() {
+            return Err(Error::request_invalid(
+                "server-side credential access boundary exchange does not accept a requested lifetime",
+            ));
+        }
+
+        let options = self.grant.options_json()?;
+        let required_until = self.required_valid_until(credential, expires_in);
+        let source = self.source_token(credential, required_until)?;
+        let request = self.build_request(&source.access_token, &options)?;
+        let response = ctx.http_send(request).await.map_err(|err| {
+            Error::new(err.kind(), "server-side CAB STS request failed")
+                .set_retryable(err.is_retryable())
+        })?;
+        self.parse_response(response, source, self.time_after_request())
+    }
+}
+
+#[derive(Deserialize)]
+struct StsTokenResponse {
+    access_token: String,
+    issued_token_type: String,
+    token_type: String,
+    #[serde(default)]
+    expires_in: Option<u64>,
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::{BTreeMap, VecDeque};
+    use std::fmt::Formatter;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::{Arc, Mutex};
+
+    use bytes::Bytes;
+    use http::header::{AUTHORIZATION, HeaderMap};
+    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer};
+
+    use super::*;
+    use crate::{CredentialAccessBoundaryPermissions, RequestSigner, ServiceAccount};
+
+    #[derive(Clone)]
+    struct CapturedRequest {
+        method: http::Method,
+        uri: http::Uri,
+        headers: HeaderMap,
+        body: Vec<u8>,
+    }
+
+    impl Debug for CapturedRequest {
+        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+            f.debug_struct("CapturedRequest")
+                .field("method", &self.method)
+                .field("uri", &self.uri)
+                .field("headers", &"REDACTED")
+                .field("body", &"REDACTED")
+                .finish()
+        }
+    }
+
+    #[derive(Clone)]
+    struct MockHttpSend {
+        calls: Arc<AtomicUsize>,
+        requests: Arc<Mutex<Vec<CapturedRequest>>>,
+        responses: Arc<Mutex<VecDeque<http::Response<Bytes>>>>,
+    }
+
+    impl Debug for MockHttpSend {
+        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+            f.debug_struct("MockHttpSend").finish_non_exhaustive()
+        }
+    }
+
+    impl MockHttpSend {
+        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
+            Self {
+                calls: Arc::new(AtomicUsize::new(0)),
+                requests: Arc::new(Mutex::new(Vec::new())),
+                responses: Arc::new(Mutex::new(responses.into_iter().collect())),
+            }
+        }
+
+        fn requests(&self) -> Vec<CapturedRequest> {
+            self.requests.lock().expect("lock poisoned").clone()
+        }
+    }
+
+    impl HttpSend for MockHttpSend {
+        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            self.calls.fetch_add(1, Ordering::SeqCst);
+            let (parts, body) = request.into_parts();
+            self.requests
+                .lock()
+                .expect("lock poisoned")
+                .push(CapturedRequest {
+                    method: parts.method,
+                    uri: parts.uri,
+                    headers: parts.headers,
+                    body: body.to_vec(),
+                });
+            self.responses
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
+        }
+    }
+
+    #[derive(Debug)]
+    struct SecretTransportError;
+
+    impl HttpSend for SecretTransportError {
+        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            Err(
+                Error::unexpected("transport captured subject_token=source-secret")
+                    .set_retryable(true),
+            )
+        }
+    }
+
+    #[derive(Clone)]
+    struct FixedCredentialProvider {
+        credential: Credential,
+        calls: Arc<AtomicUsize>,
+    }
+
+    impl Debug for FixedCredentialProvider {
+        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+            f.debug_struct("FixedCredentialProvider")
+                .finish_non_exhaustive()
+        }
+    }
+
+    impl FixedCredentialProvider {
+        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
+            let calls = Arc::new(AtomicUsize::new(0));
+            (
+                Self {
+                    credential,
+                    calls: calls.clone(),
+                },
+                calls,
+            )
+        }
+    }
+
+    impl ProvideCredential for FixedCredentialProvider {
+        type Credential = Credential;
+
+        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
+            self.calls.fetch_add(1, Ordering::SeqCst);
+            Ok(Some(self.credential.clone()))
+        }
+    }
+
+    fn timestamp(value: &str) -> Timestamp {
+        value.parse().expect("timestamp must be valid")
+    }
+
+    fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
+        Credential::with_token(Token {
+            access_token: access_token.to_string(),
+            expires_at,
+        })
+    }
+
+    fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
+        http::Response::builder()
+            .status(status)
+            .body(body.into())
+            .expect("response must build")
+    }
+
+    fn success_response(access_token: &str, expires_in: Option<u64>) -> http::Response<Bytes> {
+        let mut value = serde_json::json!({
+            "access_token": access_token,
+            "issued_token_type": ACCESS_TOKEN_TYPE,
+            "token_type": "Bearer"
+        });
+        if let Some(expires_in) = expires_in {
+            value["expires_in"] = expires_in.into();
+        }
+        response(
+            http::StatusCode::OK,
+            serde_json::to_vec(&value).expect("response JSON must serialize"),
+        )
+    }
+
+    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
+        CredentialAccessBoundaryGrant::for_bucket(
+            "example-bucket",
+            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+        )
+    }
+
+    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
+        form_urlencoded::parse(&request.body).into_owned().collect()
+    }
+
+    fn output_token(credential: &Credential) -> &Token {
+        assert!(credential.service_account.is_none());
+        credential
+            .token
+            .as_ref()
+            .expect("granted credential must contain a token")
+    }
+
+    #[tokio::test]
+    async fn sends_exact_server_side_exchange_shape() {
+        let request_time = timestamp("2030-01-01T00:00:00Z");
+        let response_time = timestamp("2030-01-01T00:00:02Z");
+        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
+        let operation = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(request_time)
+            .with_time_after_request(response_time);
+        let output = operation
+            .grant_credential(
+                &Context::new().with_http_send(http.clone()),
+                &source_token("source-token", Some(timestamp("2030-01-01T02:00:00Z"))),
+                None,
+            )
+            .await
+            .expect("token exchange must succeed");
+
+        assert_eq!(output_token(&output).access_token, "downscoped-token");
+        assert_eq!(
+            output_token(&output).expires_at,
+            Some(timestamp("2030-01-01T01:00:02Z"))
+        );
+        let requests = http.requests();
+        assert_eq!(requests.len(), 1);
+        let request = &requests[0];
+        assert_eq!(request.method, http::Method::POST);
+        assert_eq!(request.uri, STS_ENDPOINT);
+        assert_eq!(request.headers[ACCEPT], "application/json");
+        assert_eq!(
+            request.headers[CONTENT_TYPE],
+            "application/x-www-form-urlencoded"
+        );
+        assert!(!request.headers.contains_key(AUTHORIZATION));
+        assert_eq!(
+            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
+            concat!(
+                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
+                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
+                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
+                "&subject_token=source-token",
+                "&options=%7B%22accessBoundary%22%3A%7B%22accessBoundaryRules%22%3A%5B%7B",
+                "%22availableResource%22%3A%22%2F%2Fstorage.googleapis.com%2Fprojects%2F_",
+                "%2Fbuckets%2Fexample-bucket%22%2C%22availablePermissions%22%3A%5B",
+                "%22inRole%3Aroles%2Fstorage.objectViewer%22%5D%7D%5D%7D%7D"
+            )
+        );
+    }
+
+    #[tokio::test]
+    async fn form_encoding_keeps_source_and_policy_separate() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let source = "source+token/%=&options=broader";
+        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
+        let operation = ServerSideCredentialAccessBoundaryGranter::new(
+            CredentialAccessBoundaryGrant::for_object_prefix(
+                "example-bucket",
+                "tenant&rule=broader",
+                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+            ),
+        )
+        .with_time(now);
+
+        operation
+            .grant_credential(
+                &Context::new().with_http_send(http.clone()),
+                &source_token(source, Some(timestamp("2030-01-01T02:00:00Z"))),
+                None,
+            )
+            .await
+            .expect("token exchange must succeed");
+
+        let request = &http.requests()[0];
+        let fields = form_fields(request);
+        assert_eq!(fields.len(), 5);
+        assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
+        assert_eq!(fields["requested_token_type"], ACCESS_TOKEN_TYPE);
+        assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
+        assert_eq!(fields["subject_token"], source);
+        assert!(fields["options"].contains("tenant&rule=broader"));
+        let raw = String::from_utf8(request.body.clone()).expect("form body must be UTF-8");
+        assert!(raw.contains("subject_token=source%2Btoken%2F%25%3D%26options%3Dbroader"));
+        assert!(!raw.contains("&options=broader&"));
+    }
+
+    #[tokio::test]
+    async fn rejects_invalid_policy_lifetime_and_source_before_io() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let http = MockHttpSend::new([]);
+        let ctx = Context::new().with_http_send(http.clone());
+        let valid_source = source_token("source", Some(timestamp("2030-01-01T02:00:00Z")));
+
+        let invalid_grant = CredentialAccessBoundaryGrant::for_object_prefix(
+            "example-bucket",
+            "",
+            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+        );
+        let err = ServerSideCredentialAccessBoundaryGranter::new(invalid_grant)
+            .with_time(now)
+            .grant_credential(&ctx, &valid_source, None)
+            .await
+            .expect_err("invalid grant must fail");
+        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
+
+        let operation =
+            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
+        let err = operation
+            .grant_credential(&ctx, &valid_source, Some(Duration::from_secs(60)))
+            .await
+            .expect_err("server-side lifetime selection must fail");
+        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
+
+        let mut mixed = valid_source.clone();
+        mixed.service_account = Some(ServiceAccount {
+            private_key: "private-secret".to_string(),
+            client_email: "service@example.com".to_string(),
+        });
+        let invalid_sources = [
+            Credential::with_service_account(ServiceAccount {
+                private_key: "private-secret".to_string(),
+                client_email: "service@example.com".to_string(),
+            }),
+            mixed,
+            source_token("", Some(timestamp("2030-01-01T02:00:00Z"))),
+            source_token("unknown-expiration", None),
+            source_token("expiring", Some(timestamp("2030-01-01T00:00:20Z"))),
+        ];
+        for source in invalid_sources {
+            let err = operation
+                .grant_credential(&ctx, &source, None)
+                .await
+                .expect_err("incompatible source must fail");
+            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+            let debug = format!("{err:?}");
+            assert!(!debug.contains("private-secret"));
+            assert!(!debug.contains("unknown-expiration"));
+        }
+        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
+    }
+
+    #[tokio::test]
+    async fn anchors_clamps_and_revalidates_expiration_after_io() {
+        let request_time = timestamp("2030-01-01T00:00:00Z");
+        let response_time = timestamp("2030-01-01T00:00:05Z");
+        let source_expiry = timestamp("2030-01-01T00:10:00Z");
+        let http = MockHttpSend::new([
+            success_response("anchored", Some(300)),
+            success_response("inherited", None),
+            success_response("clamped", Some(3600)),
+            success_response("too-short", Some(10)),
+            success_response("source-expired", Some(3600)),
+        ]);
+        let operation = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(request_time)
+            .with_time_after_request(response_time);
+        let ctx = Context::new().with_http_send(http);
+        let source = source_token("source", Some(source_expiry));
+
+        let anchored = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("explicit expiration must succeed");
+        assert_eq!(
+            output_token(&anchored).expires_at,
+            Some(timestamp("2030-01-01T00:05:05Z"))
+        );
+        let inherited = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("missing expires_in must inherit source expiration");
+        assert_eq!(output_token(&inherited).expires_at, Some(source_expiry));
+        let clamped = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect("STS expiration must clamp to source expiration");
+        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
+
+        let err = operation
+            .grant_credential(&ctx, &source, None)
+            .await
+            .expect_err("short output must fail after I/O");
+        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+        let err = operation
+            .grant_credential(
+                &ctx,
+                &source_token("source-expired", Some(response_time)),
+                None,
+            )
+            .await
+            .expect_err("source expiry during I/O must fail");
+        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+    }
+
+    #[tokio::test]
+    async fn accepts_maximum_documented_access_token_lifetime() {
+        let response_time = timestamp("2030-01-01T00:00:05Z");
+        let output = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(timestamp("2030-01-01T00:00:00Z"))
+            .with_time_after_request(response_time)
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "downscoped-token",
+                    Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
+                )])),
+                &source_token("source-token", Some(timestamp("2030-01-02T00:00:00Z"))),
+                None,
+            )
+            .await
+            .expect("maximum documented lifetime must be accepted");
+
+        assert_eq!(
+            output_token(&output).expires_at,
+            Some(timestamp("2030-01-01T12:00:05Z"))
+        );
+    }
+
+    #[tokio::test]
+    async fn validates_malformed_success_and_sts_error_without_secrets() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let responses = [
+            response(http::StatusCode::OK, br#"{}"#.as_slice()),
+            success_response("", Some(3600)),
+            success_response("response-secret", Some(0)),
+            success_response(
+                "response-secret",
+                Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1),
+            ),
+            response(
+                http::StatusCode::BAD_REQUEST,
+                r#"{"error":"invalid_grant","error_description":"source-secret"}"#,
+            ),
+            response(
+                http::StatusCode::FORBIDDEN,
+                r#"{"error":"access_denied","error_description":"response-secret"}"#,
+            ),
+            response(
+                http::StatusCode::SERVICE_UNAVAILABLE,
+                r#"{"error":"backend_error","error_description":"response-secret"}"#,
+            ),
+        ];
+        let http = MockHttpSend::new(responses);
+        let operation =
+            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
+        let ctx = Context::new().with_http_send(http);
+        let source = source_token("source-secret", Some(timestamp("2030-01-01T13:00:00Z")));
+
+        let expected = [
+            (ErrorKind::Unexpected, false),
+            (ErrorKind::Unexpected, false),
+            (ErrorKind::Unexpected, false),
+            (ErrorKind::Unexpected, false),
+            (ErrorKind::CredentialInvalid, false),
+            (ErrorKind::PermissionDenied, false),
+            (ErrorKind::Unexpected, true),
+        ];
+        for (kind, retryable) in expected {
+            let err = operation
+                .grant_credential(&ctx, &source, None)
+                .await
+                .expect_err("invalid response must fail");
+            assert_eq!(err.kind(), kind);
+            assert_eq!(err.is_retryable(), retryable);
+            let debug = format!("{err:?}");
+            assert!(!debug.contains("source-secret"));
+            assert!(!debug.contains("response-secret"));
+        }
+    }
+
+    #[tokio::test]
+    async fn transport_error_is_redacted_and_classified() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let err = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .grant_credential(
+                &Context::new().with_http_send(SecretTransportError),
+                &source_token("source-secret", Some(timestamp("2030-01-01T01:00:00Z"))),
+                None,
+            )
+            .await
+            .expect_err("transport error must fail");
+
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+        assert!(err.is_retryable());
+        assert!(!format!("{err:?}").contains("source-secret"));
+        assert!(!format!("{err:?}").contains("transport captured"));
+    }
+
+    #[tokio::test]
+    async fn granter_caches_source_but_never_server_side_outputs() {
+        let now = Timestamp::now();
+        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
+        let (provider, provider_calls) =
+            FixedCredentialProvider::new(source_token("source", Some(source_expiry)));
+        let http = MockHttpSend::new([
+            success_response("downscoped-1", Some(3600)),
+            success_response("downscoped-2", Some(3600)),
+            response(
+                http::StatusCode::SERVICE_UNAVAILABLE,
+                r#"{"error":"backend_error","error_description":"do not return stale output"}"#,
+            ),
+        ]);
+        let granter = Granter::new(
+            Context::new().with_http_send(http.clone()),
+            provider,
+            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now),
+        );
+
+        let first = granter.grant(None).await.expect("first grant must succeed");
+        let second = granter
+            .grant(None)
+            .await
+            .expect("second grant must succeed");
+        assert_eq!(output_token(&first).access_token, "downscoped-1");
+        assert_eq!(output_token(&second).access_token, "downscoped-2");
+        let err = granter
+            .grant(None)
+            .await
+            .expect_err("failed exchange must not return stale output");
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+        assert!(err.is_retryable());
+        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
+        assert_eq!(http.calls.load(Ordering::SeqCst), 3);
+    }
+
+    #[tokio::test]
+    async fn core_granter_replacements_preserve_source_cache_isolation() {
+        let now = Timestamp::now();
+        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
+        let source = source_token("source", Some(source_expiry));
+        let (provider, provider_calls) = FixedCredentialProvider::new(source);
+        let first_http = MockHttpSend::new([
+            success_response("downscoped-1", Some(3600)),
+            success_response("downscoped-2", Some(3600)),
+            success_response("downscoped-3", Some(3600)),
+            success_response("downscoped-provider-replaced", Some(3600)),
+        ]);
+        let second_http =
+            MockHttpSend::new([success_response("downscoped-context-isolated", Some(3600))]);
+        let operation =
+            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
+        let granter = Granter::new(
+            Context::new().with_http_send(first_http.clone()),
+            provider,
+            operation.clone(),
+        );
+
+        let first = granter.grant(None).await.expect("first grant must succeed");
+        let second = granter
+            .clone()
+            .grant(None)
+            .await
+            .expect("clone grant must succeed");
+        let replaced = granter
+            .clone()
+            .with_credential_granter(operation.with_grant(
+                CredentialAccessBoundaryGrant::for_object_prefix(
+                    "example-bucket",
+                    "next/",
+                    CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
+                ),
+            ))
+            .grant(None)
+            .await
+            .expect("replacement granter must succeed");
+        let (replacement_provider, replacement_provider_calls) =
+            FixedCredentialProvider::new(source_token("replacement-source", Some(source_expiry)));
+        let provider_replaced = granter
+            .clone()
+            .with_credential_provider(replacement_provider)
+            .grant(None)
+            .await
+            .expect("replacement provider must succeed");
+        let context_isolated = granter
+            .with_context(Context::new().with_http_send(second_http.clone()))
+            .grant(None)
+            .await
+            .expect("replacement context must reload the source");
+
+        assert_eq!(output_token(&first).access_token, "downscoped-1");
+        assert_eq!(output_token(&second).access_token, "downscoped-2");
+        assert_eq!(output_token(&replaced).access_token, "downscoped-3");
+        assert_eq!(
+            output_token(&provider_replaced).access_token,
+            "downscoped-provider-replaced"
+        );
+        assert_eq!(
+            output_token(&context_isolated).access_token,
+            "downscoped-context-isolated"
+        );
+        assert_eq!(provider_calls.load(Ordering::SeqCst), 2);
+        assert_eq!(replacement_provider_calls.load(Ordering::SeqCst), 1);
+        assert_eq!(first_http.calls.load(Ordering::SeqCst), 4);
+        assert_eq!(second_http.calls.load(Ordering::SeqCst), 1);
+        assert!(
+            form_fields(&first_http.requests()[2])["options"]
+                .contains("inRole:roles/storage.objectCreator")
+        );
+        assert_eq!(
+            form_fields(&first_http.requests()[3])["subject_token"],
+            "replacement-source"
+        );
+    }
+
+    #[tokio::test]
+    async fn server_issued_token_uses_existing_google_signer() {
+        let now = Timestamp::now();
+        let output = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
+            .with_time(now)
+            .grant_credential(
+                &Context::new().with_http_send(MockHttpSend::new([success_response(
+                    "downscoped-token",
+                    Some(3600),
+                )])),
+                &source_token("source-token", Some(now + Duration::from_secs(2 * 60 * 60))),
+                None,
+            )
+            .await
+            .expect("grant must succeed");
+        let (provider, _) = FixedCredentialProvider::new(output);
+        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
+        let mut parts =
+            http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+
+        signer
+            .sign(&mut parts, None)
+            .await
+            .expect("existing signer must consume server-issued token");
+        assert_eq!(parts.headers[AUTHORIZATION], "Bearer downscoped-token");
+        assert!(parts.headers[AUTHORIZATION].is_sensitive());
+    }
+
+    #[test]
+    fn debug_redacts_owned_policy_and_credential_material() {
+        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
+            "sensitive-bucket",
+            "sensitive/prefix",
+            CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
+        );
+        let operation = ServerSideCredentialAccessBoundaryGranter::new(grant.clone());
+        let credential = source_token("sensitive-token", Some(Timestamp::now()));
+        let captured = CapturedRequest {
+            method: http::Method::POST,
+            uri: STS_ENDPOINT.parse().expect("URI must parse"),
+            headers: HeaderMap::new(),
+            body: b"subject_token=sensitive-token".to_vec(),
+        };
+
+        for (debug, secret) in [
+            (format!("{grant:?}"), "sensitive-bucket"),
+            (format!("{grant:?}"), "sensitive/prefix"),
+            (format!("{operation:?}"), "sensitive-bucket"),
+            (format!("{credential:?}"), "sensitive-token"),
+            (format!("{captured:?}"), "sensitive-token"),
+        ] {
+            assert!(!debug.contains(secret), "{debug}");
+        }
+        assert_eq!(
+            format!("{operation:?}"),
+            "ServerSideCredentialAccessBoundaryGranter { .. }"
+        );
+    }
+}
diff --git a/services/google/src/credential_access_boundary/sts.rs b/services/google/src/credential_access_boundary/sts.rs
new file mode 100644
index 0000000..a243220
--- /dev/null
+++ b/services/google/src/credential_access_boundary/sts.rs
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::time::Duration;
+
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Error, ErrorKind, Result};
+use serde::Deserialize;
+
+pub(super) const STS_ENDPOINT: &str = "https://sts.googleapis.com/v1/token";
+pub(super) const TOKEN_EXCHANGE_GRANT_TYPE: &str =
+    "urn:ietf:params:oauth:grant-type:token-exchange";
+pub(super) const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
+pub(super) const MAX_ACCESS_TOKEN_LIFETIME: Duration = Duration::from_secs(12 * 60 * 60);
+
+pub(super) fn checked_expiration(
+    response_time: Timestamp,
+    expires_in: Duration,
+) -> Result<Timestamp> {
+    let expires_in_seconds = i64::try_from(expires_in.as_secs())
+        .map_err(|_| Error::unexpected("credential access boundary STS expiration is invalid"))?;
+    let expiration_second = response_time
+        .as_second()
+        .checked_add(expires_in_seconds)
+        .ok_or_else(|| Error::unexpected("credential access boundary STS expiration is invalid"))?;
+    Timestamp::from_second(expiration_second)
+        .map_err(|_| Error::unexpected("credential access boundary STS expiration is invalid"))?;
+    Ok(response_time + expires_in)
+}
+
+#[derive(Deserialize)]
+struct StsErrorResponse {
+    #[serde(default)]
+    error: Option<String>,
+}
+
+pub(super) fn sts_error(status: http::StatusCode, body: &[u8]) -> Error {
+    let error_code = serde_json::from_slice::<StsErrorResponse>(body)
+        .ok()
+        .and_then(|response| response.error);
+    let recognized_code = match error_code.as_deref() {
+        Some(
+            code @ ("invalid_grant"
+            | "invalid_request"
+            | "invalid_target"
+            | "invalid_scope"
+            | "unsupported_grant_type"
+            | "unsupported_token_type"
+            | "unauthorized_client"
+            | "access_denied"
+            | "quota_exceeded"),
+        ) => Some(code),
+        _ => None,
+    };
+
+    let mut error = match recognized_code {
+        Some("invalid_grant") => Error::credential_invalid(
+            "credential access boundary token exchange rejected the source credential",
+        ),
+        Some(
+            "invalid_request"
+            | "invalid_target"
+            | "invalid_scope"
+            | "unsupported_grant_type"
+            | "unsupported_token_type",
+        ) => {
+            Error::request_invalid("credential access boundary token exchange rejected the request")
+        }
+        Some("unauthorized_client" | "access_denied") => {
+            Error::permission_denied("credential access boundary token exchange was denied")
+        }
+        Some("quota_exceeded") => {
+            Error::rate_limited("credential access boundary token exchange was rate limited")
+        }
+        _ if status == http::StatusCode::UNAUTHORIZED => Error::credential_invalid(
+            "credential access boundary token exchange rejected the source credential",
+        ),
+        _ if status == http::StatusCode::FORBIDDEN => {
+            Error::permission_denied("credential access boundary token exchange was denied")
+        }
+        _ if status == http::StatusCode::TOO_MANY_REQUESTS => {
+            Error::rate_limited("credential access boundary token exchange was rate limited")
+        }
+        _ => Error::unexpected("credential access boundary token exchange failed")
+            .set_retryable(status.is_server_error()),
+    }
+    .with_context(format!("sts_status: {}", status.as_u16()));
+
+    if let Some(code) = recognized_code {
+        error = error.with_context(format!("sts_error: {code}"));
+    }
+    if error.kind() == ErrorKind::Unexpected && status.is_server_error() {
+        error = error.set_retryable(true);
+    }
+    error
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn checked_expiration_rejects_timestamp_overflow() {
+        let response_time: Timestamp = "9999-12-30T21:59:59Z"
+            .parse()
+            .expect("timestamp must be valid");
+        let err = checked_expiration(response_time, Duration::from_secs(2))
+            .expect_err("expiration overflow must fail without panicking");
+        assert_eq!(err.kind(), ErrorKind::Unexpected);
+    }
+}
diff --git a/services/google/src/lib.rs b/services/google/src/lib.rs
index 37b2257..44325b3 100644
--- a/services/google/src/lib.rs
+++ b/services/google/src/lib.rs
@@ -16,6 +16,10 @@
 // under the License.
 
 //! Google Service Signer
+//!
+//! Typed server-side Cloud Storage Credential Access Boundary downscoping is
+//! always available. Enable the `credential-access-boundary-client-side`
+//! feature for local client-side token generation.
 
 mod constants;
 
@@ -23,9 +27,11 @@ mod credential;
 pub use credential::{Credential, ServiceAccount, Token};
 
 mod credential_access_boundary;
+#[cfg(feature = "credential-access-boundary-client-side")]
+pub use credential_access_boundary::ClientSideCredentialAccessBoundaryGranter;
 pub use credential_access_boundary::{
-    CredentialAccessBoundaryGrant, CredentialAccessBoundaryGranter,
-    CredentialAccessBoundaryPermissions,
+    CredentialAccessBoundaryGrant, CredentialAccessBoundaryPermissions,
+    ServerSideCredentialAccessBoundaryGranter,
 };
 
 mod sign_request;
diff --git a/services/google/tests/README.md b/services/google/tests/README.md
index 798474d..e6dcb39 100644
--- a/services/google/tests/README.md
+++ b/services/google/tests/README.md
@@ -35,12 +35,17 @@ Tests for request signing functionality:
 - `REQSIGN_GOOGLE_TEST_IMPERSONATION_DELEGATES`: Set to `on` to test impersonation with delegation chain
 - `REQSIGN_GOOGLE_TEST_VM_METADATA`: Set to `on` to enable VmMetadataCredentialProvider tests (GCP VMs only)
 - `REQSIGN_GOOGLE_TEST_VM_METADATA_MOCK`: Set to `on` to enable VmMetadataCredentialProvider tests with the local mock server
+- `REQSIGN_GOOGLE_TEST_CAB`: Set to `on` to enable the live CAB interoperability test
 
 ### Google Cloud Configuration
 - `GOOGLE_APPLICATION_CREDENTIALS`: Path to credential JSON file (supports all credential types)
 - `REQSIGN_GOOGLE_CREDENTIAL`: Base64-encoded service account JSON (for StaticCredentialProvider)
 - `REQSIGN_GOOGLE_CLOUD_STORAGE_SCOPE`: OAuth2 scope for GCS (e.g., `https://www.googleapis.com/auth/devstorage.read_write`)
 - `REQSIGN_GOOGLE_CLOUD_STORAGE_URL`: GCS bucket URL (e.g., `https://storage.googleapis.com/storage/v1/b/your-bucket`)
+- `REQSIGN_GOOGLE_CAB_SOURCE_TOKEN`: Service-account OAuth access token with the Cloud Platform scope
+- `REQSIGN_GOOGLE_CAB_SOURCE_EXPIRES_AT`: Absolute RFC 3339 expiration of the source token
+- `REQSIGN_GOOGLE_CAB_BUCKET`: Bucket used by the client-issued CAB live test
+- `REQSIGN_GOOGLE_CAB_OBJECT_PREFIX`: Non-empty object prefix used by the CAB list test
 - `GOOGLE_IMPERSONATED_CREDENTIALS`: Path to impersonated service account credential file
 - `GOOGLE_WORKLOAD_IDENTITY_PROVIDER`: Workload Identity Provider ID (for GitHub Actions)
 - `GOOGLE_SERVICE_ACCOUNT`: Service account email for Workload Identity
@@ -67,6 +72,9 @@ REQSIGN_GOOGLE_TEST_DEFAULT=on cargo test test_default_credential_provider
 
 # Run all credential provider tests
 cargo test credential_providers::
+
+# Run the live CAB STS and Cloud Storage interoperability test
+REQSIGN_GOOGLE_TEST_CAB=on cargo test --features credential-access-boundary-client-side test_client_side_credential_access_boundary_live_interoperability
 ```
 
 ### GitHub Actions
@@ -115,6 +123,9 @@ The DefaultCredentialProvider automatically detects and handles all these types.
 - Tests use real GCS API endpoints to verify signature validity
 - All tests are designed to be idempotent and safe to run repeatedly
 - Some credential provider tests use test data that will fail token exchange - this is expected
+- CAB unit tests use Google's non-secret Java auth-library keyset fixture. The live
+  CAB test is opt-in because it requires a current OAuth access token with the Cloud Platform scope
+  and a matching Cloud Storage bucket and prefix; it is not enabled by the repository CI secrets.
 
 ## Local STS Mock
 
diff --git a/services/google/tests/credential_access_boundary.rs b/services/google/tests/credential_access_boundary.rs
new file mode 100644
index 0000000..e09735a
--- /dev/null
+++ b/services/google/tests/credential_access_boundary.rs
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#![cfg(feature = "credential-access-boundary-client-side")]
+
+use std::env;
+
+use log::warn;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Granter, Result, Signer};
+use reqsign_google::{
+    ClientSideCredentialAccessBoundaryGranter, CredentialAccessBoundaryGrant,
+    CredentialAccessBoundaryPermissions, RequestSigner, TokenCredentialProvider,
+};
+use reqsign_http_send_reqwest::ReqwestHttpSend;
+
+#[tokio::test]
+async fn test_client_side_credential_access_boundary_live_interoperability() -> Result<()> {
+    let _ = env_logger::builder().is_test(true).try_init();
+    let _ = dotenvy::dotenv();
+    if env::var("REQSIGN_GOOGLE_TEST_CAB").unwrap_or_default() != "on" {
+        warn!("REQSIGN_GOOGLE_TEST_CAB is not set, skipped");
+        return Ok(());
+    }
+
+    let source_token = env::var("REQSIGN_GOOGLE_CAB_SOURCE_TOKEN")
+        .expect("REQSIGN_GOOGLE_CAB_SOURCE_TOKEN must be set");
+    let source_expires_at = env::var("REQSIGN_GOOGLE_CAB_SOURCE_EXPIRES_AT")
+        .expect("REQSIGN_GOOGLE_CAB_SOURCE_EXPIRES_AT must be set")
+        .parse::<Timestamp>()
+        .expect("REQSIGN_GOOGLE_CAB_SOURCE_EXPIRES_AT must be RFC 3339");
+    let bucket =
+        env::var("REQSIGN_GOOGLE_CAB_BUCKET").expect("REQSIGN_GOOGLE_CAB_BUCKET must be set");
+    let object_prefix = env::var("REQSIGN_GOOGLE_CAB_OBJECT_PREFIX")
+        .expect("REQSIGN_GOOGLE_CAB_OBJECT_PREFIX must be set");
+
+    let context = Context::new().with_http_send(ReqwestHttpSend::default());
+    let grant = CredentialAccessBoundaryGrant::for_object_prefix(
+        &bucket,
+        &object_prefix,
+        CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
+    );
+    let downscoped = Granter::new(
+        context,
+        TokenCredentialProvider::new(source_token).with_expires_at(source_expires_at),
+        ClientSideCredentialAccessBoundaryGranter::new(grant),
+    )
+    .grant(None)
+    .await?;
+    let token = downscoped
+        .token
+        .expect("client-side CAB output must contain a token");
+    let expires_at = token
+        .expires_at
+        .expect("client-side CAB output must have an expiration");
+    let signer = Signer::new(
+        Context::new(),
+        TokenCredentialProvider::new(token.access_token).with_expires_at(expires_at),
+        RequestSigner::new("storage"),
+    );
+
+    let mut url = reqwest::Url::parse(&format!(
+        "https://storage.googleapis.com/storage/v1/b/{bucket}/o"
+    ))
+    .expect("Cloud Storage URL must be valid");
+    url.query_pairs_mut()
+        .append_pair("maxResults", "1")
+        .append_pair("prefix", &object_prefix);
+    let request = http::Request::get(url.as_str())
+        .body("")
+        .expect("Cloud Storage request must build");
+    let (mut parts, body) = request.into_parts();
+    signer.sign(&mut parts, None).await?;
+    let response = reqwest::Client::new()
+        .execute(
+            http::Request::from_parts(parts, body)
+                .try_into()
+                .map_err(|err| {
+                    reqsign_core::Error::unexpected(
+                        "failed to convert client-side CAB live request",
+                    )
+                    .with_source(err)
+                })?,
+        )
+        .await
+        .map_err(|err| {
+            reqsign_core::Error::unexpected("client-side CAB live request failed").with_source(err)
+        })?;
+    assert!(
+        response.status().is_success(),
+        "client-issued CAB token was rejected with status {}",
+        response.status()
+    );
+
+    let disallowed_prefix = if object_prefix.starts_with('a') {
+        "b-reqsign-cab-out-of-scope/"
+    } else {
+        "a-reqsign-cab-out-of-scope/"
+    };
+    let mut url = reqwest::Url::parse(&format!(
+        "https://storage.googleapis.com/storage/v1/b/{bucket}/o"
+    ))
+    .expect("Cloud Storage URL must be valid");
+    url.query_pairs_mut()
+        .append_pair("maxResults", "1")
+        .append_pair("prefix", disallowed_prefix);
+    let request = http::Request::get(url.as_str())
+        .body("")
+        .expect("Cloud Storage request must build");
+    let (mut parts, body) = request.into_parts();
+    signer.sign(&mut parts, None).await?;
+    let response = reqwest::Client::new()
+        .execute(
+            http::Request::from_parts(parts, body)
+                .try_into()
+                .map_err(|err| {
+                    reqsign_core::Error::unexpected(
+                        "failed to convert out-of-scope client-side CAB live request",
+                    )
+                    .with_source(err)
+                })?,
+        )
+        .await
+        .map_err(|err| {
+            reqsign_core::Error::unexpected("out-of-scope client-side CAB live request failed")
+                .with_source(err)
+        })?;
+    assert_eq!(
+        response.status(),
+        reqwest::StatusCode::FORBIDDEN,
+        "client-issued CAB token unexpectedly accessed an out-of-scope prefix"
+    );
+    Ok(())
+}