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::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}