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::json!({
473            "access_token": access_token,
474            "issued_token_type": ACCESS_TOKEN_TYPE,
475            "token_type": "Bearer"
476        });
477        if let Some(expires_in) = expires_in {
478            value["expires_in"] = expires_in.into();
479        }
480        response(
481            http::StatusCode::OK,
482            serde_json::to_vec(&value).expect("response JSON must serialize"),
483        )
484    }
485
486    fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
487        CredentialAccessBoundaryGrant::for_bucket(
488            "example-bucket",
489            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
490        )
491    }
492
493    fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
494        form_urlencoded::parse(&request.body).into_owned().collect()
495    }
496
497    fn output_token(credential: &Credential) -> &Token {
498        assert!(credential.service_account.is_none());
499        credential
500            .token
501            .as_ref()
502            .expect("granted credential must contain a token")
503    }
504
505    #[tokio::test]
506    async fn sends_exact_server_side_exchange_shape() {
507        let request_time = timestamp("2030-01-01T00:00:00Z");
508        let response_time = timestamp("2030-01-01T00:00:02Z");
509        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
510        let operation = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
511            .with_time(request_time)
512            .with_time_after_request(response_time);
513        let output = operation
514            .grant_credential(
515                &Context::new().with_http_send(http.clone()),
516                &source_token("source-token", Some(timestamp("2030-01-01T02:00:00Z"))),
517                None,
518            )
519            .await
520            .expect("token exchange must succeed");
521
522        assert_eq!(output_token(&output).access_token, "downscoped-token");
523        assert_eq!(
524            output_token(&output).expires_at,
525            Some(timestamp("2030-01-01T01:00:02Z"))
526        );
527        let requests = http.requests();
528        assert_eq!(requests.len(), 1);
529        let request = &requests[0];
530        assert_eq!(request.method, http::Method::POST);
531        assert_eq!(request.uri, STS_ENDPOINT);
532        assert_eq!(request.headers[ACCEPT], "application/json");
533        assert_eq!(
534            request.headers[CONTENT_TYPE],
535            "application/x-www-form-urlencoded"
536        );
537        assert!(!request.headers.contains_key(AUTHORIZATION));
538        assert_eq!(
539            String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
540            concat!(
541                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
542                "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
543                "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
544                "&subject_token=source-token",
545                "&options=%7B%22accessBoundary%22%3A%7B%22accessBoundaryRules%22%3A%5B%7B",
546                "%22availableResource%22%3A%22%2F%2Fstorage.googleapis.com%2Fprojects%2F_",
547                "%2Fbuckets%2Fexample-bucket%22%2C%22availablePermissions%22%3A%5B",
548                "%22inRole%3Aroles%2Fstorage.objectViewer%22%5D%7D%5D%7D%7D"
549            )
550        );
551    }
552
553    #[tokio::test]
554    async fn form_encoding_keeps_source_and_policy_separate() {
555        let now = timestamp("2030-01-01T00:00:00Z");
556        let source = "source+token/%=&options=broader";
557        let http = MockHttpSend::new([success_response("downscoped-token", Some(3600))]);
558        let operation = ServerSideCredentialAccessBoundaryGranter::new(
559            CredentialAccessBoundaryGrant::for_object_prefix(
560                "example-bucket",
561                "tenant&rule=broader",
562                CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
563            ),
564        )
565        .with_time(now);
566
567        operation
568            .grant_credential(
569                &Context::new().with_http_send(http.clone()),
570                &source_token(source, Some(timestamp("2030-01-01T02:00:00Z"))),
571                None,
572            )
573            .await
574            .expect("token exchange must succeed");
575
576        let request = &http.requests()[0];
577        let fields = form_fields(request);
578        assert_eq!(fields.len(), 5);
579        assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
580        assert_eq!(fields["requested_token_type"], ACCESS_TOKEN_TYPE);
581        assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
582        assert_eq!(fields["subject_token"], source);
583        assert!(fields["options"].contains("tenant&rule=broader"));
584        let raw = String::from_utf8(request.body.clone()).expect("form body must be UTF-8");
585        assert!(raw.contains("subject_token=source%2Btoken%2F%25%3D%26options%3Dbroader"));
586        assert!(!raw.contains("&options=broader&"));
587    }
588
589    #[tokio::test]
590    async fn rejects_invalid_policy_lifetime_and_source_before_io() {
591        let now = timestamp("2030-01-01T00:00:00Z");
592        let http = MockHttpSend::new([]);
593        let ctx = Context::new().with_http_send(http.clone());
594        let valid_source = source_token("source", Some(timestamp("2030-01-01T02:00:00Z")));
595
596        let invalid_grant = CredentialAccessBoundaryGrant::for_object_prefix(
597            "example-bucket",
598            "",
599            CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
600        );
601        let err = ServerSideCredentialAccessBoundaryGranter::new(invalid_grant)
602            .with_time(now)
603            .grant_credential(&ctx, &valid_source, None)
604            .await
605            .expect_err("invalid grant must fail");
606        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
607
608        let operation =
609            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
610        let err = operation
611            .grant_credential(&ctx, &valid_source, Some(Duration::from_secs(60)))
612            .await
613            .expect_err("server-side lifetime selection must fail");
614        assert_eq!(err.kind(), ErrorKind::RequestInvalid);
615
616        let mut mixed = valid_source.clone();
617        mixed.service_account = Some(ServiceAccount {
618            private_key: "private-secret".to_string(),
619            client_email: "service@example.com".to_string(),
620        });
621        let invalid_sources = [
622            Credential::with_service_account(ServiceAccount {
623                private_key: "private-secret".to_string(),
624                client_email: "service@example.com".to_string(),
625            }),
626            mixed,
627            source_token("", Some(timestamp("2030-01-01T02:00:00Z"))),
628            source_token("unknown-expiration", None),
629            source_token("expiring", Some(timestamp("2030-01-01T00:00:20Z"))),
630        ];
631        for source in invalid_sources {
632            let err = operation
633                .grant_credential(&ctx, &source, None)
634                .await
635                .expect_err("incompatible source must fail");
636            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
637            let debug = format!("{err:?}");
638            assert!(!debug.contains("private-secret"));
639            assert!(!debug.contains("unknown-expiration"));
640        }
641        assert_eq!(http.calls.load(Ordering::SeqCst), 0);
642    }
643
644    #[tokio::test]
645    async fn anchors_clamps_and_revalidates_expiration_after_io() {
646        let request_time = timestamp("2030-01-01T00:00:00Z");
647        let response_time = timestamp("2030-01-01T00:00:05Z");
648        let source_expiry = timestamp("2030-01-01T00:10:00Z");
649        let http = MockHttpSend::new([
650            success_response("anchored", Some(300)),
651            success_response("inherited", None),
652            success_response("clamped", Some(3600)),
653            success_response("too-short", Some(10)),
654            success_response("source-expired", Some(3600)),
655        ]);
656        let operation = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
657            .with_time(request_time)
658            .with_time_after_request(response_time);
659        let ctx = Context::new().with_http_send(http);
660        let source = source_token("source", Some(source_expiry));
661
662        let anchored = operation
663            .grant_credential(&ctx, &source, None)
664            .await
665            .expect("explicit expiration must succeed");
666        assert_eq!(
667            output_token(&anchored).expires_at,
668            Some(timestamp("2030-01-01T00:05:05Z"))
669        );
670        let inherited = operation
671            .grant_credential(&ctx, &source, None)
672            .await
673            .expect("missing expires_in must inherit source expiration");
674        assert_eq!(output_token(&inherited).expires_at, Some(source_expiry));
675        let clamped = operation
676            .grant_credential(&ctx, &source, None)
677            .await
678            .expect("STS expiration must clamp to source expiration");
679        assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
680
681        let err = operation
682            .grant_credential(&ctx, &source, None)
683            .await
684            .expect_err("short output must fail after I/O");
685        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
686        let err = operation
687            .grant_credential(
688                &ctx,
689                &source_token("source-expired", Some(response_time)),
690                None,
691            )
692            .await
693            .expect_err("source expiry during I/O must fail");
694        assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
695    }
696
697    #[tokio::test]
698    async fn accepts_maximum_documented_access_token_lifetime() {
699        let response_time = timestamp("2030-01-01T00:00:05Z");
700        let output = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
701            .with_time(timestamp("2030-01-01T00:00:00Z"))
702            .with_time_after_request(response_time)
703            .grant_credential(
704                &Context::new().with_http_send(MockHttpSend::new([success_response(
705                    "downscoped-token",
706                    Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
707                )])),
708                &source_token("source-token", Some(timestamp("2030-01-02T00:00:00Z"))),
709                None,
710            )
711            .await
712            .expect("maximum documented lifetime must be accepted");
713
714        assert_eq!(
715            output_token(&output).expires_at,
716            Some(timestamp("2030-01-01T12:00:05Z"))
717        );
718    }
719
720    #[tokio::test]
721    async fn validates_malformed_success_and_sts_error_without_secrets() {
722        let now = timestamp("2030-01-01T00:00:00Z");
723        let responses = [
724            response(http::StatusCode::OK, br#"{}"#.as_slice()),
725            success_response("", Some(3600)),
726            success_response("response-secret", Some(0)),
727            success_response(
728                "response-secret",
729                Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1),
730            ),
731            response(
732                http::StatusCode::BAD_REQUEST,
733                r#"{"error":"invalid_grant","error_description":"source-secret"}"#,
734            ),
735            response(
736                http::StatusCode::FORBIDDEN,
737                r#"{"error":"access_denied","error_description":"response-secret"}"#,
738            ),
739            response(
740                http::StatusCode::SERVICE_UNAVAILABLE,
741                r#"{"error":"backend_error","error_description":"response-secret"}"#,
742            ),
743        ];
744        let http = MockHttpSend::new(responses);
745        let operation =
746            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
747        let ctx = Context::new().with_http_send(http);
748        let source = source_token("source-secret", Some(timestamp("2030-01-01T13:00:00Z")));
749
750        let expected = [
751            (ErrorKind::Unexpected, false),
752            (ErrorKind::Unexpected, false),
753            (ErrorKind::Unexpected, false),
754            (ErrorKind::Unexpected, false),
755            (ErrorKind::CredentialInvalid, false),
756            (ErrorKind::PermissionDenied, false),
757            (ErrorKind::Unexpected, true),
758        ];
759        for (kind, retryable) in expected {
760            let err = operation
761                .grant_credential(&ctx, &source, None)
762                .await
763                .expect_err("invalid response must fail");
764            assert_eq!(err.kind(), kind);
765            assert_eq!(err.is_retryable(), retryable);
766            let debug = format!("{err:?}");
767            assert!(!debug.contains("source-secret"));
768            assert!(!debug.contains("response-secret"));
769        }
770    }
771
772    #[tokio::test]
773    async fn transport_error_is_redacted_and_classified() {
774        let now = timestamp("2030-01-01T00:00:00Z");
775        let err = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
776            .with_time(now)
777            .grant_credential(
778                &Context::new().with_http_send(SecretTransportError),
779                &source_token("source-secret", Some(timestamp("2030-01-01T01:00:00Z"))),
780                None,
781            )
782            .await
783            .expect_err("transport error must fail");
784
785        assert_eq!(err.kind(), ErrorKind::Unexpected);
786        assert!(err.is_retryable());
787        assert!(!format!("{err:?}").contains("source-secret"));
788        assert!(!format!("{err:?}").contains("transport captured"));
789    }
790
791    #[tokio::test]
792    async fn granter_caches_source_but_never_server_side_outputs() {
793        let now = Timestamp::now();
794        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
795        let (provider, provider_calls) =
796            FixedCredentialProvider::new(source_token("source", Some(source_expiry)));
797        let http = MockHttpSend::new([
798            success_response("downscoped-1", Some(3600)),
799            success_response("downscoped-2", Some(3600)),
800            response(
801                http::StatusCode::SERVICE_UNAVAILABLE,
802                r#"{"error":"backend_error","error_description":"do not return stale output"}"#,
803            ),
804        ]);
805        let granter = Granter::new(
806            Context::new().with_http_send(http.clone()),
807            provider,
808            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now),
809        );
810
811        let first = granter.grant(None).await.expect("first grant must succeed");
812        let second = granter
813            .grant(None)
814            .await
815            .expect("second grant must succeed");
816        assert_eq!(output_token(&first).access_token, "downscoped-1");
817        assert_eq!(output_token(&second).access_token, "downscoped-2");
818        let err = granter
819            .grant(None)
820            .await
821            .expect_err("failed exchange must not return stale output");
822        assert_eq!(err.kind(), ErrorKind::Unexpected);
823        assert!(err.is_retryable());
824        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
825        assert_eq!(http.calls.load(Ordering::SeqCst), 3);
826    }
827
828    #[tokio::test]
829    async fn core_granter_replacements_preserve_source_cache_isolation() {
830        let now = Timestamp::now();
831        let source_expiry = now + Duration::from_secs(2 * 60 * 60);
832        let source = source_token("source", Some(source_expiry));
833        let (provider, provider_calls) = FixedCredentialProvider::new(source);
834        let first_http = MockHttpSend::new([
835            success_response("downscoped-1", Some(3600)),
836            success_response("downscoped-2", Some(3600)),
837            success_response("downscoped-3", Some(3600)),
838            success_response("downscoped-provider-replaced", Some(3600)),
839        ]);
840        let second_http =
841            MockHttpSend::new([success_response("downscoped-context-isolated", Some(3600))]);
842        let operation =
843            ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
844        let granter = Granter::new(
845            Context::new().with_http_send(first_http.clone()),
846            provider,
847            operation.clone(),
848        );
849
850        let first = granter.grant(None).await.expect("first grant must succeed");
851        let second = granter
852            .clone()
853            .grant(None)
854            .await
855            .expect("clone grant must succeed");
856        let replaced = granter
857            .clone()
858            .with_credential_granter(operation.with_grant(
859                CredentialAccessBoundaryGrant::for_object_prefix(
860                    "example-bucket",
861                    "next/",
862                    CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
863                ),
864            ))
865            .grant(None)
866            .await
867            .expect("replacement granter must succeed");
868        let (replacement_provider, replacement_provider_calls) =
869            FixedCredentialProvider::new(source_token("replacement-source", Some(source_expiry)));
870        let provider_replaced = granter
871            .clone()
872            .with_credential_provider(replacement_provider)
873            .grant(None)
874            .await
875            .expect("replacement provider must succeed");
876        let context_isolated = granter
877            .with_context(Context::new().with_http_send(second_http.clone()))
878            .grant(None)
879            .await
880            .expect("replacement context must reload the source");
881
882        assert_eq!(output_token(&first).access_token, "downscoped-1");
883        assert_eq!(output_token(&second).access_token, "downscoped-2");
884        assert_eq!(output_token(&replaced).access_token, "downscoped-3");
885        assert_eq!(
886            output_token(&provider_replaced).access_token,
887            "downscoped-provider-replaced"
888        );
889        assert_eq!(
890            output_token(&context_isolated).access_token,
891            "downscoped-context-isolated"
892        );
893        assert_eq!(provider_calls.load(Ordering::SeqCst), 2);
894        assert_eq!(replacement_provider_calls.load(Ordering::SeqCst), 1);
895        assert_eq!(first_http.calls.load(Ordering::SeqCst), 4);
896        assert_eq!(second_http.calls.load(Ordering::SeqCst), 1);
897        assert!(
898            form_fields(&first_http.requests()[2])["options"]
899                .contains("inRole:roles/storage.objectCreator")
900        );
901        assert_eq!(
902            form_fields(&first_http.requests()[3])["subject_token"],
903            "replacement-source"
904        );
905    }
906
907    #[tokio::test]
908    async fn server_issued_token_uses_existing_google_signer() {
909        let now = Timestamp::now();
910        let output = ServerSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
911            .with_time(now)
912            .grant_credential(
913                &Context::new().with_http_send(MockHttpSend::new([success_response(
914                    "downscoped-token",
915                    Some(3600),
916                )])),
917                &source_token("source-token", Some(now + Duration::from_secs(2 * 60 * 60))),
918                None,
919            )
920            .await
921            .expect("grant must succeed");
922        let (provider, _) = FixedCredentialProvider::new(output);
923        let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
924        let mut parts =
925            http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
926                .body(())
927                .expect("request must build")
928                .into_parts()
929                .0;
930
931        signer
932            .sign(&mut parts, None)
933            .await
934            .expect("existing signer must consume server-issued token");
935        assert_eq!(parts.headers[AUTHORIZATION], "Bearer downscoped-token");
936        assert!(parts.headers[AUTHORIZATION].is_sensitive());
937    }
938
939    #[test]
940    fn debug_redacts_owned_policy_and_credential_material() {
941        let grant = CredentialAccessBoundaryGrant::for_object_prefix(
942            "sensitive-bucket",
943            "sensitive/prefix",
944            CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
945        );
946        let operation = ServerSideCredentialAccessBoundaryGranter::new(grant.clone());
947        let credential = source_token("sensitive-token", Some(Timestamp::now()));
948        let captured = CapturedRequest {
949            method: http::Method::POST,
950            uri: STS_ENDPOINT.parse().expect("URI must parse"),
951            headers: HeaderMap::new(),
952            body: b"subject_token=sensitive-token".to_vec(),
953        };
954
955        for (debug, secret) in [
956            (format!("{grant:?}"), "sensitive-bucket"),
957            (format!("{grant:?}"), "sensitive/prefix"),
958            (format!("{operation:?}"), "sensitive-bucket"),
959            (format!("{credential:?}"), "sensitive-token"),
960            (format!("{captured:?}"), "sensitive-token"),
961        ] {
962            assert!(!debug.contains(secret), "{debug}");
963        }
964        assert_eq!(
965            format!("{operation:?}"),
966            "ServerSideCredentialAccessBoundaryGranter { .. }"
967        );
968    }
969}