1use 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#[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 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 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}