Skip to main content

reqsign_google/credential_access_boundary/
server_side.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::fmt::{self, Debug};
19use std::time::Duration;
20
21use form_urlencoded::Serializer;
22use http::header::{ACCEPT, CONTENT_TYPE};
23use reqsign_core::time::Timestamp;
24use reqsign_core::{Context, Error, GrantCredential, Result, SigningCredential};
25use serde::Deserialize;
26
27use super::CredentialAccessBoundaryGrant;
28use super::sts::{
29    ACCESS_TOKEN_TYPE, MAX_ACCESS_TOKEN_LIFETIME, STS_ENDPOINT, TOKEN_EXCHANGE_GRANT_TYPE,
30    checked_expiration, sts_error,
31};
32use crate::constants::TOKEN_OPERATION_HEADROOM;
33use crate::{Credential, Token};
34
35const TOKEN_EXCHANGE_HEADROOM: Duration = Duration::from_secs(10);
36
37/// Exchanges a Google OAuth access token for a server-issued CAB token.
38///
39/// The bound grant is stable configuration. Construct another granter (or use
40/// [`ServerSideCredentialAccessBoundaryGranter::with_grant`]) for a different
41/// authorization decision. The server-side CAB exchange does not accept a
42/// requested lifetime, so [`reqsign_core::Granter::grant`] must be called with
43/// `None`.
44///
45/// The source must be a token-only [`Credential`] containing a Google-issued
46/// OAuth access token with a known absolute expiration and the
47/// `https://www.googleapis.com/auth/cloud-platform` scope. Server-issued CAB
48/// tokens support user and service-account principals. STS rejects tokens that
49/// already carry security attributes; the opaque token string does not expose
50/// enough information to detect its principal, scope, or existing attributes
51/// locally. Credentials with unknown expiration or an attached service account
52/// are rejected before STS I/O.
53///
54/// The returned [`Credential`] is token-only and can be consumed directly by
55/// the existing Google [`crate::RequestSigner`]. Every grant performs a new STS
56/// exchange; the service layer does not cache granted outputs.
57///
58/// # Example
59///
60/// ```no_run
61/// use std::time::Duration;
62///
63/// use reqsign_core::{Context, Granter, time::Timestamp};
64/// use reqsign_google::{
65///     CredentialAccessBoundaryGrant, CredentialAccessBoundaryPermissions,
66///     ServerSideCredentialAccessBoundaryGranter, TokenCredentialProvider,
67/// };
68///
69/// # async fn example() -> reqsign_core::Result<()> {
70/// let source = TokenCredentialProvider::new("source-oauth-token")
71///     .with_expires_at(Timestamp::now() + Duration::from_secs(3600));
72/// let grant = CredentialAccessBoundaryGrant::for_object_prefix(
73///     "example-bucket",
74///     "customer-a/",
75///     CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
76/// );
77/// // Supply a Context configured with an HttpSend implementation.
78/// let context = Context::new();
79/// let credential = Granter::new(
80///     context,
81///     source,
82///     ServerSideCredentialAccessBoundaryGranter::new(grant),
83/// )
84/// .grant(None)
85/// .await?;
86/// # let _ = credential;
87/// # Ok(())
88/// # }
89/// ```
90#[derive(Clone)]
91pub struct ServerSideCredentialAccessBoundaryGranter {
92    grant: CredentialAccessBoundaryGrant,
93    #[cfg(test)]
94    now: Option<Timestamp>,
95    #[cfg(test)]
96    time_after_request: Option<Timestamp>,
97}
98
99impl Debug for ServerSideCredentialAccessBoundaryGranter {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.debug_struct("ServerSideCredentialAccessBoundaryGranter")
102            .finish_non_exhaustive()
103    }
104}
105
106impl ServerSideCredentialAccessBoundaryGranter {
107    /// Create a server-side granter for a bound Credential Access Boundary.
108    pub fn new(grant: CredentialAccessBoundaryGrant) -> Self {
109        Self {
110            grant,
111            #[cfg(test)]
112            now: None,
113            #[cfg(test)]
114            time_after_request: None,
115        }
116    }
117
118    /// Replace the bound grant.
119    pub fn with_grant(mut self, grant: CredentialAccessBoundaryGrant) -> Self {
120        self.grant = grant;
121        self
122    }
123
124    fn now(&self) -> Timestamp {
125        #[cfg(test)]
126        if let Some(now) = self.now {
127            return now;
128        }
129        Timestamp::now()
130    }
131
132    fn time_after_request(&self) -> Timestamp {
133        #[cfg(test)]
134        if let Some(now) = self.time_after_request {
135            return now;
136        }
137        #[cfg(test)]
138        if let Some(now) = self.now {
139            return now;
140        }
141        Timestamp::now()
142    }
143
144    #[cfg(test)]
145    fn with_time(mut self, now: Timestamp) -> Self {
146        self.now = Some(now);
147        self.time_after_request = Some(now);
148        self
149    }
150
151    #[cfg(test)]
152    fn with_time_after_request(mut self, now: Timestamp) -> Self {
153        self.time_after_request = Some(now);
154        self
155    }
156
157    fn source_token<'a>(
158        &self,
159        credential: &'a Credential,
160        required_until: Timestamp,
161    ) -> Result<&'a Token> {
162        if credential.service_account.is_some() {
163            return Err(Error::credential_invalid(
164                "server-side credential access boundary exchange requires a token-only source credential",
165            ));
166        }
167        let token = credential.token.as_ref().ok_or_else(|| {
168            Error::credential_invalid(
169                "server-side credential access boundary exchange requires an OAuth access token",
170            )
171        })?;
172        if token.access_token.is_empty() {
173            return Err(Error::credential_invalid(
174                "server-side credential access boundary source access token is empty",
175            ));
176        }
177        if token.expires_at.is_none() {
178            return Err(Error::credential_invalid(
179                "server-side credential access boundary source token expiration is required",
180            ));
181        }
182        if !token.is_valid_at(required_until) {
183            return Err(Error::credential_invalid(
184                "source OAuth access token expires before the server-side CAB exchange can complete",
185            ));
186        }
187        Ok(token)
188    }
189
190    fn build_request(
191        &self,
192        source_token: &str,
193        options: &str,
194    ) -> Result<http::Request<bytes::Bytes>> {
195        let body = Serializer::new(String::new())
196            .append_pair("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
197            .append_pair("requested_token_type", ACCESS_TOKEN_TYPE)
198            .append_pair("subject_token_type", ACCESS_TOKEN_TYPE)
199            .append_pair("subject_token", source_token)
200            .append_pair("options", options)
201            .finish();
202
203        http::Request::builder()
204            .method(http::Method::POST)
205            .uri(STS_ENDPOINT)
206            .header(ACCEPT, "application/json")
207            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
208            .body(body.into_bytes().into())
209            .map_err(|err| {
210                Error::unexpected("failed to build server-side CAB request").with_source(err)
211            })
212    }
213
214    fn parse_response(
215        &self,
216        response: http::Response<bytes::Bytes>,
217        source: &Token,
218        response_time: Timestamp,
219    ) -> Result<Credential> {
220        if response.status() != http::StatusCode::OK {
221            return Err(sts_error(response.status(), response.body()));
222        }
223
224        let token_response: StsTokenResponse = serde_json::from_slice(response.body())
225            .map_err(|_| Error::unexpected("failed to parse server-side CAB STS response"))?;
226        if token_response.access_token.is_empty()
227            || token_response.issued_token_type != ACCESS_TOKEN_TYPE
228            || token_response.token_type != "Bearer"
229        {
230            return Err(Error::unexpected(
231                "server-side CAB STS response is malformed",
232            ));
233        }
234
235        let source_expiration = source.expires_at.ok_or_else(|| {
236            Error::credential_invalid(
237                "server-side credential access boundary source token expiration is required",
238            )
239        })?;
240        if source_expiration <= response_time {
241            return Err(Error::credential_invalid(
242                "source OAuth access token expired during the server-side CAB exchange",
243            ));
244        }
245
246        let response_expiration = token_response
247            .expires_in
248            .map(|expires_in| {
249                let expires_in = Duration::from_secs(expires_in);
250                if expires_in.is_zero() || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
251                    return Err(Error::unexpected(
252                        "server-side CAB STS expiration is invalid",
253                    ));
254                }
255                checked_expiration(response_time, expires_in)
256            })
257            .transpose()?;
258        let expires_at = response_expiration
259            .map(|response| response.min(source_expiration))
260            .unwrap_or(source_expiration);
261        if expires_at <= response_time {
262            return Err(Error::unexpected(
263                "server-side CAB STS token is already expired",
264            ));
265        }
266
267        let credential = Credential::with_token(Token {
268            access_token: token_response.access_token,
269            expires_at: Some(expires_at),
270        });
271        let required_until = checked_expiration(response_time, TOKEN_OPERATION_HEADROOM)?;
272        if !credential.is_valid_at(required_until) {
273            return Err(Error::credential_invalid(
274                "server-issued CAB token is not valid long enough for Google signing",
275            ));
276        }
277        Ok(credential)
278    }
279}
280
281impl GrantCredential for ServerSideCredentialAccessBoundaryGranter {
282    type Credential = Credential;
283
284    fn required_valid_until(
285        &self,
286        _credential: &Self::Credential,
287        _expires_in: Option<Duration>,
288    ) -> Timestamp {
289        self.now() + TOKEN_EXCHANGE_HEADROOM + TOKEN_OPERATION_HEADROOM
290    }
291
292    async fn grant_credential(
293        &self,
294        ctx: &Context,
295        credential: &Self::Credential,
296        expires_in: Option<Duration>,
297    ) -> Result<Self::Credential> {
298        if expires_in.is_some() {
299            return Err(Error::request_invalid(
300                "server-side credential access boundary exchange does not accept a requested lifetime",
301            ));
302        }
303
304        let options = self.grant.options_json()?;
305        let required_until = self.required_valid_until(credential, expires_in);
306        let source = self.source_token(credential, required_until)?;
307        let request = self.build_request(&source.access_token, &options)?;
308        let response = ctx.http_send(request).await.map_err(|err| {
309            Error::new(err.kind(), "server-side CAB STS request failed")
310                .set_retryable(err.is_retryable())
311        })?;
312        self.parse_response(response, source, self.time_after_request())
313    }
314}
315
316#[derive(Deserialize)]
317struct StsTokenResponse {
318    access_token: String,
319    issued_token_type: String,
320    token_type: String,
321    #[serde(default)]
322    expires_in: Option<u64>,
323}
324
325#[cfg(test)]
326mod tests {
327    use std::collections::{BTreeMap, VecDeque};
328    use std::fmt::Formatter;
329    use std::sync::atomic::{AtomicUsize, Ordering};
330    use std::sync::{Arc, Mutex};
331
332    use bytes::Bytes;
333    use http::header::{AUTHORIZATION, HeaderMap};
334    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer};
335
336    use super::*;
337    use crate::{CredentialAccessBoundaryPermissions, RequestSigner, ServiceAccount};
338
339    #[derive(Clone)]
340    struct CapturedRequest {
341        method: http::Method,
342        uri: http::Uri,
343        headers: HeaderMap,
344        body: Vec<u8>,
345    }
346
347    impl Debug for CapturedRequest {
348        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
349            f.debug_struct("CapturedRequest")
350                .field("method", &self.method)
351                .field("uri", &self.uri)
352                .field("headers", &"REDACTED")
353                .field("body", &"REDACTED")
354                .finish()
355        }
356    }
357
358    #[derive(Clone)]
359    struct MockHttpSend {
360        calls: Arc<AtomicUsize>,
361        requests: Arc<Mutex<Vec<CapturedRequest>>>,
362        responses: Arc<Mutex<VecDeque<http::Response<Bytes>>>>,
363    }
364
365    impl Debug for MockHttpSend {
366        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
367            f.debug_struct("MockHttpSend").finish_non_exhaustive()
368        }
369    }
370
371    impl MockHttpSend {
372        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
373            Self {
374                calls: Arc::new(AtomicUsize::new(0)),
375                requests: Arc::new(Mutex::new(Vec::new())),
376                responses: Arc::new(Mutex::new(responses.into_iter().collect())),
377            }
378        }
379
380        fn requests(&self) -> Vec<CapturedRequest> {
381            self.requests.lock().expect("lock poisoned").clone()
382        }
383    }
384
385    impl HttpSend for MockHttpSend {
386        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
387            self.calls.fetch_add(1, Ordering::SeqCst);
388            let (parts, body) = request.into_parts();
389            self.requests
390                .lock()
391                .expect("lock poisoned")
392                .push(CapturedRequest {
393                    method: parts.method,
394                    uri: parts.uri,
395                    headers: parts.headers,
396                    body: body.to_vec(),
397                });
398            self.responses
399                .lock()
400                .expect("lock poisoned")
401                .pop_front()
402                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
403        }
404    }
405
406    #[derive(Debug)]
407    struct SecretTransportError;
408
409    impl HttpSend for SecretTransportError {
410        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
411            Err(
412                Error::unexpected("transport captured subject_token=source-secret")
413                    .set_retryable(true),
414            )
415        }
416    }
417
418    #[derive(Clone)]
419    struct FixedCredentialProvider {
420        credential: Credential,
421        calls: Arc<AtomicUsize>,
422    }
423
424    impl Debug for FixedCredentialProvider {
425        fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
426            f.debug_struct("FixedCredentialProvider")
427                .finish_non_exhaustive()
428        }
429    }
430
431    impl FixedCredentialProvider {
432        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
433            let calls = Arc::new(AtomicUsize::new(0));
434            (
435                Self {
436                    credential,
437                    calls: calls.clone(),
438                },
439                calls,
440            )
441        }
442    }
443
444    impl ProvideCredential for FixedCredentialProvider {
445        type Credential = Credential;
446
447        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
448            self.calls.fetch_add(1, Ordering::SeqCst);
449            Ok(Some(self.credential.clone()))
450        }
451    }
452
453    fn timestamp(value: &str) -> Timestamp {
454        value.parse().expect("timestamp must be valid")
455    }
456
457    fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
458        Credential::with_token(Token {
459            access_token: access_token.to_string(),
460            expires_at,
461        })
462    }
463
464    fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
465        http::Response::builder()
466            .status(status)
467            .body(body.into())
468            .expect("response must build")
469    }
470
471    fn success_response(access_token: &str, expires_in: Option<u64>) -> http::Response<Bytes> {
472        let mut value: serde_json::Value = serde_json::from_slice(include_bytes!(
473            "../../tests/fixtures/server_side_cab_sts_response.json"
474        ))
475        .expect("real server-side CAB STS response fixture must parse");
476        value["access_token"] = access_token.into();
477        if let Some(expires_in) = expires_in {
478            value["expires_in"] = expires_in.into();
479        } else {
480            value
481                .as_object_mut()
482                .expect("STS response fixture must be an object")
483                .remove("expires_in");
484        }
485        response(
486            http::StatusCode::OK,
487            serde_json::to_vec(&value).expect("response JSON must serialize"),
488        )
489    }
490
491    #[test]
492    fn parses_redacted_real_server_side_sts_response() {
493        let response: StsTokenResponse = serde_json::from_slice(include_bytes!(
494            "../../tests/fixtures/server_side_cab_sts_response.json"
495        ))
496        .expect("real server-side CAB STS response fixture must parse");
497
498        assert_eq!(response.access_token, "REDACTED");
499        assert_eq!(response.issued_token_type, ACCESS_TOKEN_TYPE);
500        assert_eq!(response.token_type, "Bearer");
501        assert_eq!(response.expires_in, None);
502    }
503
504    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
505        CredentialAccessBoundaryGrant::for_bucket(
506            "example-bucket",
507            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
508        )
509    }
510
511    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
512        form_urlencoded::parse(&request.body).into_owned().collect()
513    }
514
515    fn output_token(credential: &Credential) -> &Token {
516        assert!(credential.service_account.is_none());
517        credential
518            .token
519            .as_ref()
520            .expect("granted credential must contain a token")
521    }
522
523    #[tokio::test]
524    async fn sends_exact_server_side_exchange_shape() {
525        let request_time = timestamp("2030-01-01T00:00:00Z");
526        let response_time = timestamp("2030-01-01T00:00:02Z");
527        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
528        let operation = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
529            .with_time(request_time)
530            .with_time_after_request(response_time);
531        let output = operation
532            .grant_credential(
533                &Context::new().with_http_send(http.clone()),
534                &source_token("source-token", Some(timestamp("2030-01-01T02:00:00Z"))),
535                None,
536            )
537            .await
538            .expect("token exchange must succeed");
539
540        assert_eq!(output_token(&output).access_token, "downscoped-token");
541        assert_eq!(
542            output_token(&output).expires_at,
543            Some(timestamp("2030-01-01T01:00:02Z"))
544        );
545        let requests = http.requests();
546        assert_eq!(requests.len(), 1);
547        let request = &requests[0];
548        assert_eq!(request.method, http::Method::POST);
549        assert_eq!(request.uri, STS_ENDPOINT);
550        assert_eq!(request.headers[ACCEPT], "application/json");
551        assert_eq!(
552            request.headers[CONTENT_TYPE],
553            "application/x-www-form-urlencoded"
554        );
555        assert!(!request.headers.contains_key(AUTHORIZATION));
556        assert_eq!(
557            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
558            concat!(
559                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
560                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
561                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
562                "&subject_token=source-token",
563                "&options=%7B%22accessBoundary%22%3A%7B%22accessBoundaryRules%22%3A%5B%7B",
564                "%22availableResource%22%3A%22%2F%2Fstorage.googleapis.com%2Fprojects%2F_",
565                "%2Fbuckets%2Fexample-bucket%22%2C%22availablePermissions%22%3A%5B",
566                "%22inRole%3Aroles%2Fstorage.objectViewer%22%5D%7D%5D%7D%7D"
567            )
568        );
569    }
570
571    #[tokio::test]
572    async fn form_encoding_keeps_source_and_policy_separate() {
573        let now = timestamp("2030-01-01T00:00:00Z");
574        let source = "source+token/%=&options=broader";
575        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
576        let operation = ServerSideCredentialAccessBoundaryGranter::new(
577            CredentialAccessBoundaryGrant::for_object_prefix(
578                "example-bucket",
579                "tenant&rule=broader",
580                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
581            ),
582        )
583        .with_time(now);
584
585        operation
586            .grant_credential(
587                &Context::new().with_http_send(http.clone()),
588                &source_token(source, Some(timestamp("2030-01-01T02:00:00Z"))),
589                None,
590            )
591            .await
592            .expect("token exchange must succeed");
593
594        let request = &http.requests()[0];
595        let fields = form_fields(request);
596        assert_eq!(fields.len(), 5);
597        assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
598        assert_eq!(fields["requested_token_type"], ACCESS_TOKEN_TYPE);
599        assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
600        assert_eq!(fields["subject_token"], source);
601        assert!(fields["options"].contains("tenant&rule=broader"));
602        let raw = String::from_utf8(request.body.clone()).expect("form body must be UTF-8");
603        assert!(raw.contains("subject_token=source%2Btoken%2F%25%3D%26options%3Dbroader"));
604        assert!(!raw.contains("&options=broader&"));
605    }
606
607    #[tokio::test]
608    async fn rejects_invalid_policy_lifetime_and_source_before_io() {
609        let now = timestamp("2030-01-01T00:00:00Z");
610        let http = MockHttpSend::new([]);
611        let ctx = Context::new().with_http_send(http.clone());
612        let valid_source = source_token("source", Some(timestamp("2030-01-01T02:00:00Z")));
613
614        let invalid_grant = CredentialAccessBoundaryGrant::for_object_prefix(
615            "example-bucket",
616            "",
617            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
618        );
619        let err = ServerSideCredentialAccessBoundaryGranter::new(invalid_grant)
620            .with_time(now)
621            .grant_credential(&ctx, &valid_source, None)
622            .await
623            .expect_err("invalid grant must fail");
624        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
625
626        let operation =
627            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
628        let err = operation
629            .grant_credential(&ctx, &valid_source, Some(Duration::from_secs(60)))
630            .await
631            .expect_err("server-side lifetime selection must fail");
632        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
633
634        let mut mixed = valid_source.clone();
635        mixed.service_account = Some(ServiceAccount {
636            private_key: "private-secret".to_string(),
637            client_email: "service@example.com".to_string(),
638        });
639        let invalid_sources = [
640            Credential::with_service_account(ServiceAccount {
641                private_key: "private-secret".to_string(),
642                client_email: "service@example.com".to_string(),
643            }),
644            mixed,
645            source_token("", Some(timestamp("2030-01-01T02:00:00Z"))),
646            source_token("unknown-expiration", None),
647            source_token("expiring", Some(timestamp("2030-01-01T00:00:20Z"))),
648        ];
649        for source in invalid_sources {
650            let err = operation
651                .grant_credential(&ctx, &source, None)
652                .await
653                .expect_err("incompatible source must fail");
654            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
655            let debug = format!("{err:?}");
656            assert!(!debug.contains("private-secret"));
657            assert!(!debug.contains("unknown-expiration"));
658        }
659        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
660    }
661
662    #[tokio::test]
663    async fn anchors_clamps_and_revalidates_expiration_after_io() {
664        let request_time = timestamp("2030-01-01T00:00:00Z");
665        let response_time = timestamp("2030-01-01T00:00:05Z");
666        let source_expiry = timestamp("2030-01-01T00:10:00Z");
667        let http = MockHttpSend::new([
668            success_response("anchored", Some(300)),
669            success_response("inherited", None),
670            success_response("clamped", Some(3600)),
671            success_response("too-short", Some(10)),
672            success_response("source-expired", Some(3600)),
673        ]);
674        let operation = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
675            .with_time(request_time)
676            .with_time_after_request(response_time);
677        let ctx = Context::new().with_http_send(http);
678        let source = source_token("source", Some(source_expiry));
679
680        let anchored = operation
681            .grant_credential(&ctx, &source, None)
682            .await
683            .expect("explicit expiration must succeed");
684        assert_eq!(
685            output_token(&anchored).expires_at,
686            Some(timestamp("2030-01-01T00:05:05Z"))
687        );
688        let inherited = operation
689            .grant_credential(&ctx, &source, None)
690            .await
691            .expect("missing expires_in must inherit source expiration");
692        assert_eq!(output_token(&inherited).expires_at, Some(source_expiry));
693        let clamped = operation
694            .grant_credential(&ctx, &source, None)
695            .await
696            .expect("STS expiration must clamp to source expiration");
697        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
698
699        let err = operation
700            .grant_credential(&ctx, &source, None)
701            .await
702            .expect_err("short output must fail after I/O");
703        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
704        let err = operation
705            .grant_credential(
706                &ctx,
707                &source_token("source-expired", Some(response_time)),
708                None,
709            )
710            .await
711            .expect_err("source expiry during I/O must fail");
712        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
713    }
714
715    #[tokio::test]
716    async fn accepts_maximum_documented_access_token_lifetime() {
717        let response_time = timestamp("2030-01-01T00:00:05Z");
718        let output = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
719            .with_time(timestamp("2030-01-01T00:00:00Z"))
720            .with_time_after_request(response_time)
721            .grant_credential(
722                &Context::new().with_http_send(MockHttpSend::new([success_response(
723                    "downscoped-token",
724                    Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
725                )])),
726                &source_token("source-token", Some(timestamp("2030-01-02T00:00:00Z"))),
727                None,
728            )
729            .await
730            .expect("maximum documented lifetime must be accepted");
731
732        assert_eq!(
733            output_token(&output).expires_at,
734            Some(timestamp("2030-01-01T12:00:05Z"))
735        );
736    }
737
738    #[tokio::test]
739    async fn validates_malformed_success_and_sts_error_without_secrets() {
740        let now = timestamp("2030-01-01T00:00:00Z");
741        let responses = [
742            response(http::StatusCode::OK, br#"{}"#.as_slice()),
743            success_response("", Some(3600)),
744            success_response("response-secret", Some(0)),
745            success_response(
746                "response-secret",
747                Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1),
748            ),
749            response(
750                http::StatusCode::BAD_REQUEST,
751                r#"{"error":"invalid_grant","error_description":"source-secret"}"#,
752            ),
753            response(
754                http::StatusCode::FORBIDDEN,
755                r#"{"error":"access_denied","error_description":"response-secret"}"#,
756            ),
757            response(
758                http::StatusCode::SERVICE_UNAVAILABLE,
759                r#"{"error":"backend_error","error_description":"response-secret"}"#,
760            ),
761        ];
762        let http = MockHttpSend::new(responses);
763        let operation =
764            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
765        let ctx = Context::new().with_http_send(http);
766        let source = source_token("source-secret", Some(timestamp("2030-01-01T13:00:00Z")));
767
768        let expected = [
769            (ErrorKind::Unexpected, false),
770            (ErrorKind::Unexpected, false),
771            (ErrorKind::Unexpected, false),
772            (ErrorKind::Unexpected, false),
773            (ErrorKind::CredentialInvalid, false),
774            (ErrorKind::PermissionDenied, false),
775            (ErrorKind::Unexpected, true),
776        ];
777        for (kind, retryable) in expected {
778            let err = operation
779                .grant_credential(&ctx, &source, None)
780                .await
781                .expect_err("invalid response must fail");
782            assert_eq!(err.kind(), kind);
783            assert_eq!(err.is_retryable(), retryable);
784            let debug = format!("{err:?}");
785            assert!(!debug.contains("source-secret"));
786            assert!(!debug.contains("response-secret"));
787        }
788    }
789
790    #[tokio::test]
791    async fn transport_error_is_redacted_and_classified() {
792        let now = timestamp("2030-01-01T00:00:00Z");
793        let err = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
794            .with_time(now)
795            .grant_credential(
796                &Context::new().with_http_send(SecretTransportError),
797                &source_token("source-secret", Some(timestamp("2030-01-01T01:00:00Z"))),
798                None,
799            )
800            .await
801            .expect_err("transport error must fail");
802
803        assert_eq!(err.kind(), ErrorKind::Unexpected);
804        assert!(err.is_retryable());
805        assert!(!format!("{err:?}").contains("source-secret"));
806        assert!(!format!("{err:?}").contains("transport captured"));
807    }
808
809    #[tokio::test]
810    async fn granter_caches_source_but_never_server_side_outputs() {
811        let now = Timestamp::now();
812        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
813        let (provider, provider_calls) =
814            FixedCredentialProvider::new(source_token("source", Some(source_expiry)));
815        let http = MockHttpSend::new([
816            success_response("downscoped-1", Some(3600)),
817            success_response("downscoped-2", Some(3600)),
818            response(
819                http::StatusCode::SERVICE_UNAVAILABLE,
820                r#"{"error":"backend_error","error_description":"do not return stale output"}"#,
821            ),
822        ]);
823        let granter = Granter::new(
824            Context::new().with_http_send(http.clone()),
825            provider,
826            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now),
827        );
828
829        let first = granter.grant(None).await.expect("first grant must succeed");
830        let second = granter
831            .grant(None)
832            .await
833            .expect("second grant must succeed");
834        assert_eq!(output_token(&first).access_token, "downscoped-1");
835        assert_eq!(output_token(&second).access_token, "downscoped-2");
836        let err = granter
837            .grant(None)
838            .await
839            .expect_err("failed exchange must not return stale output");
840        assert_eq!(err.kind(), ErrorKind::Unexpected);
841        assert!(err.is_retryable());
842        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
843        assert_eq!(http.calls.load(Ordering::SeqCst), 3);
844    }
845
846    #[tokio::test]
847    async fn core_granter_replacements_preserve_source_cache_isolation() {
848        let now = Timestamp::now();
849        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
850        let source = source_token("source", Some(source_expiry));
851        let (provider, provider_calls) = FixedCredentialProvider::new(source);
852        let first_http = MockHttpSend::new([
853            success_response("downscoped-1", Some(3600)),
854            success_response("downscoped-2", Some(3600)),
855            success_response("downscoped-3", Some(3600)),
856            success_response("downscoped-provider-replaced", Some(3600)),
857        ]);
858        let second_http =
859            MockHttpSend::new([success_response("downscoped-context-isolated", Some(3600))]);
860        let operation =
861            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
862        let granter = Granter::new(
863            Context::new().with_http_send(first_http.clone()),
864            provider,
865            operation.clone(),
866        );
867
868        let first = granter.grant(None).await.expect("first grant must succeed");
869        let second = granter
870            .clone()
871            .grant(None)
872            .await
873            .expect("clone grant must succeed");
874        let replaced = granter
875            .clone()
876            .with_credential_granter(operation.with_grant(
877                CredentialAccessBoundaryGrant::for_object_prefix(
878                    "example-bucket",
879                    "next/",
880                    CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
881                ),
882            ))
883            .grant(None)
884            .await
885            .expect("replacement granter must succeed");
886        let (replacement_provider, replacement_provider_calls) =
887            FixedCredentialProvider::new(source_token("replacement-source", Some(source_expiry)));
888        let provider_replaced = granter
889            .clone()
890            .with_credential_provider(replacement_provider)
891            .grant(None)
892            .await
893            .expect("replacement provider must succeed");
894        let context_isolated = granter
895            .with_context(Context::new().with_http_send(second_http.clone()))
896            .grant(None)
897            .await
898            .expect("replacement context must reload the source");
899
900        assert_eq!(output_token(&first).access_token, "downscoped-1");
901        assert_eq!(output_token(&second).access_token, "downscoped-2");
902        assert_eq!(output_token(&replaced).access_token, "downscoped-3");
903        assert_eq!(
904            output_token(&provider_replaced).access_token,
905            "downscoped-provider-replaced"
906        );
907        assert_eq!(
908            output_token(&context_isolated).access_token,
909            "downscoped-context-isolated"
910        );
911        assert_eq!(provider_calls.load(Ordering::SeqCst), 2);
912        assert_eq!(replacement_provider_calls.load(Ordering::SeqCst), 1);
913        assert_eq!(first_http.calls.load(Ordering::SeqCst), 4);
914        assert_eq!(second_http.calls.load(Ordering::SeqCst), 1);
915        assert!(
916            form_fields(&first_http.requests()[2])["options"]
917                .contains("inRole:roles/storage.objectCreator")
918        );
919        assert_eq!(
920            form_fields(&first_http.requests()[3])["subject_token"],
921            "replacement-source"
922        );
923    }
924
925    #[tokio::test]
926    async fn server_issued_token_uses_existing_google_signer() {
927        let now = Timestamp::now();
928        let output = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
929            .with_time(now)
930            .grant_credential(
931                &Context::new().with_http_send(MockHttpSend::new([success_response(
932                    "downscoped-token",
933                    Some(3600),
934                )])),
935                &source_token("source-token", Some(now + Duration::from_secs(2 * 60 * 60))),
936                None,
937            )
938            .await
939            .expect("grant must succeed");
940        let (provider, _) = FixedCredentialProvider::new(output);
941        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
942        let mut parts =
943            http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
944                .body(())
945                .expect("request must build")
946                .into_parts()
947                .0;
948
949        signer
950            .sign(&mut parts, None)
951            .await
952            .expect("existing signer must consume server-issued token");
953        assert_eq!(parts.headers[AUTHORIZATION], "Bearer downscoped-token");
954        assert!(parts.headers[AUTHORIZATION].is_sensitive());
955    }
956
957    #[test]
958    fn debug_redacts_owned_policy_and_credential_material() {
959        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
960            "sensitive-bucket",
961            "sensitive/prefix",
962            CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
963        );
964        let operation = ServerSideCredentialAccessBoundaryGranter::new(grant.clone());
965        let credential = source_token("sensitive-token", Some(Timestamp::now()));
966        let captured = CapturedRequest {
967            method: http::Method::POST,
968            uri: STS_ENDPOINT.parse().expect("URI must parse"),
969            headers: HeaderMap::new(),
970            body: b"subject_token=sensitive-token".to_vec(),
971        };
972
973        for (debug, secret) in [
974            (format!("{grant:?}"), "sensitive-bucket"),
975            (format!("{grant:?}"), "sensitive/prefix"),
976            (format!("{operation:?}"), "sensitive-bucket"),
977            (format!("{credential:?}"), "sensitive-token"),
978            (format!("{captured:?}"), "sensitive-token"),
979        ] {
980            assert!(!debug.contains(secret), "{debug}");
981        }
982        assert_eq!(
983            format!("{operation:?}"),
984            "ServerSideCredentialAccessBoundaryGranter { .. }"
985        );
986    }
987}