1use std::fmt::{self, Debug, Formatter};
19use std::time::Duration;
20
21use form_urlencoded::Serializer;
22use http::header::CONTENT_TYPE;
23use log::debug;
24use reqsign_core::time::Timestamp;
25use reqsign_core::{
26 Context, Error, ErrorKind, ProvideCredential, ProvideCredentialDyn, Result, SigningCredential,
27};
28use serde::{Deserialize, Serialize};
29
30use crate::constants::{DEFAULT_SCOPE, GOOGLE_SCOPE, TOKEN_OPERATION_HEADROOM};
31use crate::credential::{Credential, ServiceAccount, Token};
32
33const OAUTH_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
34const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
35const JWT_LIFETIME: Duration = Duration::from_secs(3600);
36
37#[derive(Debug, Serialize)]
38struct Claims<'a> {
39 iss: &'a str,
40 scope: &'a str,
41 aud: &'static str,
42 exp: u64,
43 iat: u64,
44}
45
46impl<'a> Claims<'a> {
47 fn new(client_email: &'a str, scope: &'a str, now: Timestamp) -> Result<Self> {
48 let iat = u64::try_from(now.as_second())
49 .map_err(|_| Error::unexpected("service account JWT timestamp is invalid"))?;
50 let exp = iat
51 .checked_add(JWT_LIFETIME.as_secs())
52 .ok_or_else(|| Error::unexpected("service account JWT timestamp is invalid"))?;
53 Ok(Self {
54 iss: client_email,
55 scope,
56 aud: OAUTH_TOKEN_ENDPOINT,
57 exp,
58 iat,
59 })
60 }
61}
62
63#[derive(Debug, Serialize)]
64struct JwtHeader {
65 alg: &'static str,
66 typ: &'static str,
67}
68
69impl JwtHeader {
70 fn rs256() -> Self {
71 Self {
72 alg: "RS256",
73 typ: "JWT",
74 }
75 }
76}
77
78#[derive(Deserialize)]
79struct TokenResponse {
80 access_token: String,
81 expires_in: u64,
82}
83
84#[derive(Deserialize)]
85struct OAuthErrorResponse {
86 #[serde(default)]
87 error: Option<String>,
88}
89
90enum Source {
91 ServiceAccount(ServiceAccount),
92 Provider(Box<dyn ProvideCredentialDyn<Credential = Credential>>),
93}
94
95pub struct ServiceAccountTokenCredentialProvider {
140 source: Source,
141 scope: Option<String>,
142}
143
144impl Debug for ServiceAccountTokenCredentialProvider {
145 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
146 f.debug_struct("ServiceAccountTokenCredentialProvider")
147 .field("scope", &self.scope)
148 .finish_non_exhaustive()
149 }
150}
151
152impl ServiceAccountTokenCredentialProvider {
153 pub fn new(service_account: ServiceAccount) -> Self {
155 Self {
156 source: Source::ServiceAccount(service_account),
157 scope: None,
158 }
159 }
160
161 pub fn from_provider(
167 provider: impl ProvideCredential<Credential = Credential> + 'static,
168 ) -> Self {
169 Self {
170 source: Source::Provider(Box::new(provider)),
171 scope: None,
172 }
173 }
174
175 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
180 self.scope = Some(scope.into());
181 self
182 }
183
184 async fn service_account(&self, ctx: &Context) -> Result<Option<ServiceAccount>> {
185 let service_account = match &self.source {
186 Source::ServiceAccount(service_account) => service_account.clone(),
187 Source::Provider(provider) => {
188 let credential = match provider.provide_credential_dyn(ctx).await? {
189 Some(credential) => credential,
190 None => return Ok(None),
191 };
192 credential.service_account.ok_or_else(|| {
193 Error::credential_invalid(
194 "service account OAuth token provider requires a service-account credential",
195 )
196 })?
197 }
198 };
199
200 if !service_account.is_valid() {
201 return Err(Error::credential_invalid(
202 "service account OAuth token provider requires a client email and private key",
203 ));
204 }
205 Ok(Some(service_account))
206 }
207}
208
209impl ProvideCredential for ServiceAccountTokenCredentialProvider {
210 type Credential = Credential;
211
212 async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
213 let Some(service_account) = self.service_account(ctx).await? else {
214 return Ok(None);
215 };
216 let token =
217 exchange_service_account_token(ctx, &service_account, self.scope.as_deref()).await?;
218 Ok(Some(
219 Credential::with_token(token).with_signer_email(service_account.client_email),
220 ))
221 }
222}
223
224fn resolve_scope(ctx: &Context, scope: Option<&str>) -> String {
225 scope
226 .map(ToOwned::to_owned)
227 .or_else(|| ctx.env_var(GOOGLE_SCOPE))
228 .unwrap_or_else(|| DEFAULT_SCOPE.to_string())
229}
230
231async fn exchange_service_account_token(
232 ctx: &Context,
233 service_account: &ServiceAccount,
234 scope: Option<&str>,
235) -> Result<Token> {
236 if !service_account.is_valid() {
237 return Err(Error::credential_invalid(
238 "service account OAuth exchange requires a client email and private key",
239 ));
240 }
241
242 let scope = resolve_scope(ctx, scope);
243 debug!("exchanging service account for token with scope: {scope}");
244 let request = build_token_request(service_account, &scope, Timestamp::now())?;
245 let request_started_at = Timestamp::now();
246 let response = ctx.http_send(request).await.map_err(|err| {
247 Error::new(err.kind(), "service account OAuth token request failed")
248 .set_retryable(err.is_retryable())
249 })?;
250 let completed_at = Timestamp::now();
251
252 if response.status() != http::StatusCode::OK {
253 return Err(oauth_error(response.status(), response.body()));
254 }
255 let token = parse_token_response(response.body(), request_started_at)?;
256 let required_until = checked_expiration(completed_at, TOKEN_OPERATION_HEADROOM)?;
257 if !token.is_valid_at(required_until) {
258 return Err(Error::credential_invalid(
259 "service account OAuth token is not valid long enough for Google signing",
260 ));
261 }
262 Ok(token)
263}
264
265fn build_token_request(
266 service_account: &ServiceAccount,
267 scope: &str,
268 now: Timestamp,
269) -> Result<http::Request<bytes::Bytes>> {
270 let jwt = reqsign_core::jwt::encode_rs256_pem(
271 &JwtHeader::rs256(),
272 &Claims::new(&service_account.client_email, scope, now)?,
273 service_account.private_key.as_bytes(),
274 )?;
275 let body = Serializer::new(String::new())
276 .append_pair("grant_type", JWT_BEARER_GRANT_TYPE)
277 .append_pair("assertion", &jwt)
278 .finish();
279
280 http::Request::builder()
281 .method(http::Method::POST)
282 .uri(OAUTH_TOKEN_ENDPOINT)
283 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
284 .body(body.into_bytes().into())
285 .map_err(|err| {
286 Error::unexpected("failed to build service account OAuth token request")
287 .with_source(err)
288 })
289}
290
291fn parse_token_response(body: &[u8], request_started_at: Timestamp) -> Result<Token> {
292 let response: TokenResponse = serde_json::from_slice(body)
293 .map_err(|_| Error::unexpected("failed to parse service account OAuth token response"))?;
294 if response.access_token.is_empty() || response.expires_in == 0 {
295 return Err(Error::unexpected(
296 "service account OAuth token response is malformed",
297 ));
298 }
299 let expires_at =
300 checked_expiration(request_started_at, Duration::from_secs(response.expires_in))?;
301 Ok(Token {
302 access_token: response.access_token,
303 expires_at: Some(expires_at),
304 })
305}
306
307fn checked_expiration(started_at: Timestamp, expires_in: Duration) -> Result<Timestamp> {
308 let expires_in_seconds = i64::try_from(expires_in.as_secs())
309 .map_err(|_| Error::unexpected("service account OAuth token expiration is invalid"))?;
310 let expiration_second = started_at
311 .as_second()
312 .checked_add(expires_in_seconds)
313 .ok_or_else(|| Error::unexpected("service account OAuth token expiration is invalid"))?;
314 Timestamp::from_second(expiration_second)
315 .map_err(|_| Error::unexpected("service account OAuth token expiration is invalid"))
316}
317
318fn oauth_error(status: http::StatusCode, body: &[u8]) -> Error {
319 let error_code = serde_json::from_slice::<OAuthErrorResponse>(body)
320 .ok()
321 .and_then(|response| response.error);
322 let recognized_code = match error_code.as_deref() {
323 Some(
324 code @ ("invalid_grant"
325 | "invalid_request"
326 | "invalid_scope"
327 | "unsupported_grant_type"
328 | "unauthorized_client"
329 | "invalid_client"
330 | "access_denied"
331 | "temporarily_unavailable"),
332 ) => Some(code),
333 _ => None,
334 };
335
336 let mut error = match recognized_code {
337 Some("invalid_grant" | "invalid_client") => Error::credential_invalid(
338 "service account OAuth token exchange rejected the source credential",
339 ),
340 Some("invalid_request" | "invalid_scope" | "unsupported_grant_type") => {
341 Error::request_invalid("service account OAuth token exchange rejected the request")
342 }
343 Some("unauthorized_client" | "access_denied") => {
344 Error::permission_denied("service account OAuth token exchange was denied")
345 }
346 Some("temporarily_unavailable") => {
347 Error::unexpected("service account OAuth token exchange is temporarily unavailable")
348 .set_retryable(true)
349 }
350 _ if status == http::StatusCode::UNAUTHORIZED => Error::credential_invalid(
351 "service account OAuth token exchange rejected the source credential",
352 ),
353 _ if status == http::StatusCode::FORBIDDEN => {
354 Error::permission_denied("service account OAuth token exchange was denied")
355 }
356 _ if status == http::StatusCode::TOO_MANY_REQUESTS => {
357 Error::rate_limited("service account OAuth token exchange was rate limited")
358 }
359 _ => Error::unexpected("service account OAuth token exchange failed")
360 .set_retryable(status.is_server_error()),
361 }
362 .with_context(format!("oauth_status: {}", status.as_u16()));
363
364 if let Some(code) = recognized_code {
365 error = error.with_context(format!("oauth_error: {code}"));
366 }
367 if error.kind() == ErrorKind::Unexpected && status.is_server_error() {
368 error = error.set_retryable(true);
369 }
370 error
371}
372
373#[cfg(test)]
374mod tests {
375 use std::collections::{BTreeMap, HashMap, VecDeque};
376 use std::sync::{Arc, Mutex};
377
378 use bytes::Bytes;
379 use http::{HeaderMap, Method};
380 use reqsign_core::hash::base64_decode;
381 use reqsign_core::{FileRead, Granter, HttpSend, StaticEnv, time::Timestamp};
382 use rsa::RsaPrivateKey;
383 use rsa::pkcs8::{EncodePrivateKey, LineEnding};
384 use rsa::rand_core::OsRng;
385 use serde_json::Value;
386
387 use crate::{
388 CredentialAccessBoundaryGrant, CredentialAccessBoundaryPermissions,
389 DefaultCredentialProvider, FileCredentialProvider,
390 ServerSideCredentialAccessBoundaryGranter, StaticCredentialProvider,
391 };
392
393 use super::*;
394
395 #[derive(Debug)]
396 struct CapturedRequest {
397 method: Method,
398 uri: String,
399 headers: HeaderMap,
400 body: Vec<u8>,
401 }
402
403 #[derive(Clone, Debug, Default)]
404 struct MockHttpSend {
405 responses: Arc<Mutex<VecDeque<http::Response<Bytes>>>>,
406 requests: Arc<Mutex<Vec<CapturedRequest>>>,
407 }
408
409 impl MockHttpSend {
410 fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
411 Self {
412 responses: Arc::new(Mutex::new(responses.into_iter().collect())),
413 requests: Arc::new(Mutex::new(Vec::new())),
414 }
415 }
416
417 fn requests(&self) -> std::sync::MutexGuard<'_, Vec<CapturedRequest>> {
418 self.requests.lock().expect("lock must not be poisoned")
419 }
420 }
421
422 impl HttpSend for MockHttpSend {
423 async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
424 let (parts, body) = request.into_parts();
425 self.requests
426 .lock()
427 .expect("lock must not be poisoned")
428 .push(CapturedRequest {
429 method: parts.method,
430 uri: parts.uri.to_string(),
431 headers: parts.headers,
432 body: body.to_vec(),
433 });
434 self.responses
435 .lock()
436 .expect("lock must not be poisoned")
437 .pop_front()
438 .ok_or_else(|| Error::unexpected("mock HTTP response is missing"))
439 }
440 }
441
442 #[derive(Clone, Debug)]
443 struct FixedFileRead {
444 content: Arc<Vec<u8>>,
445 }
446
447 impl FixedFileRead {
448 fn new(content: impl Into<Vec<u8>>) -> Self {
449 Self {
450 content: Arc::new(content.into()),
451 }
452 }
453 }
454
455 impl FileRead for FixedFileRead {
456 async fn file_read(&self, _path: &str) -> Result<Vec<u8>> {
457 Ok(self.content.as_ref().clone())
458 }
459 }
460
461 #[derive(Debug)]
462 struct FixedProvider {
463 credential: Option<Credential>,
464 }
465
466 impl ProvideCredential for FixedProvider {
467 type Credential = Credential;
468
469 async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
470 Ok(self.credential.clone())
471 }
472 }
473
474 fn service_account() -> ServiceAccount {
475 let key = RsaPrivateKey::new(&mut OsRng, 1024).expect("test RSA key must generate");
476 let private_key = key
477 .to_pkcs8_pem(LineEnding::LF)
478 .expect("test RSA key must encode")
479 .to_string();
480 ServiceAccount {
481 private_key,
482 client_email: "provider@example.iam.gserviceaccount.com".to_string(),
483 }
484 }
485
486 fn service_account_json(service_account: &ServiceAccount) -> String {
487 serde_json::json!({
488 "type": "service_account",
489 "private_key": service_account.private_key,
490 "client_email": service_account.client_email,
491 })
492 .to_string()
493 }
494
495 fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
496 http::Response::builder()
497 .status(status)
498 .body(body.into())
499 .expect("response must build")
500 }
501
502 fn oauth_success(access_token: &str, expires_in: u64) -> http::Response<Bytes> {
503 response(
504 http::StatusCode::OK,
505 serde_json::to_vec(&serde_json::json!({
506 "access_token": access_token,
507 "expires_in": expires_in,
508 "token_type": "Bearer",
509 }))
510 .expect("response JSON must serialize"),
511 )
512 }
513
514 fn cab_success(access_token: &str, expires_in: u64) -> http::Response<Bytes> {
515 response(
516 http::StatusCode::OK,
517 serde_json::to_vec(&serde_json::json!({
518 "access_token": access_token,
519 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
520 "token_type": "Bearer",
521 "expires_in": expires_in,
522 }))
523 .expect("response JSON must serialize"),
524 )
525 }
526
527 fn form_fields(body: &[u8]) -> BTreeMap<String, String> {
528 form_urlencoded::parse(body).into_owned().collect()
529 }
530
531 fn decode_jwt_json(segment: &str) -> Value {
532 let mut standard = segment.replace('-', "+").replace('_', "/");
533 while standard.len() % 4 != 0 {
534 standard.push('=');
535 }
536 serde_json::from_slice(&base64_decode(&standard).expect("JWT segment must decode"))
537 .expect("JWT segment must contain JSON")
538 }
539
540 #[tokio::test]
541 async fn exchanges_service_account_with_exact_request_shape() -> Result<()> {
542 let http = MockHttpSend::new([oauth_success("source-token", 3600)]);
543 let service_account = service_account();
544 let provider = ServiceAccountTokenCredentialProvider::new(service_account.clone())
545 .with_scope("scope-a scope-b");
546 let output = provider
547 .provide_credential(&Context::new().with_http_send(http.clone()))
548 .await?
549 .expect("credential must be returned");
550
551 assert!(output.service_account.is_none());
552 assert_eq!(
553 output.signer_email.as_deref(),
554 Some(service_account.client_email.as_str())
555 );
556 let token = output.token.expect("token must be present");
557 assert_eq!(token.access_token, "source-token");
558 assert!(token.expires_at.is_some());
559
560 let requests = http.requests();
561 assert_eq!(requests.len(), 1);
562 let request = &requests[0];
563 assert_eq!(request.method, Method::POST);
564 assert_eq!(request.uri, OAUTH_TOKEN_ENDPOINT);
565 assert_eq!(
566 request.headers[CONTENT_TYPE],
567 "application/x-www-form-urlencoded"
568 );
569
570 let fields = form_fields(&request.body);
571 assert_eq!(fields["grant_type"], JWT_BEARER_GRANT_TYPE);
572 let segments = fields["assertion"].split('.').collect::<Vec<_>>();
573 assert_eq!(segments.len(), 3);
574 assert_eq!(
575 decode_jwt_json(segments[0]),
576 serde_json::json!({"alg": "RS256", "typ": "JWT"})
577 );
578 let claims = decode_jwt_json(segments[1]);
579 assert_eq!(claims["iss"], service_account.client_email);
580 assert_eq!(claims["scope"], "scope-a scope-b");
581 assert_eq!(claims["aud"], OAUTH_TOKEN_ENDPOINT);
582 assert_eq!(
583 claims["exp"].as_u64().expect("exp must be an integer")
584 - claims["iat"].as_u64().expect("iat must be an integer"),
585 JWT_LIFETIME.as_secs()
586 );
587 Ok(())
588 }
589
590 #[test]
591 fn scope_precedence_is_explicit_then_environment_then_default() {
592 let ctx = Context::new().with_env(StaticEnv {
593 home_dir: None,
594 envs: HashMap::from([(GOOGLE_SCOPE.to_string(), "environment-scope".to_string())]),
595 });
596 assert_eq!(
597 resolve_scope(&ctx, Some("explicit-scope")),
598 "explicit-scope"
599 );
600 assert_eq!(resolve_scope(&ctx, None), "environment-scope");
601 assert_eq!(resolve_scope(&Context::new(), None), DEFAULT_SCOPE);
602 }
603
604 #[tokio::test]
605 async fn rejects_invalid_source_before_oauth_io_and_preserves_none() -> Result<()> {
606 let http = MockHttpSend::default();
607 let ctx = Context::new().with_http_send(http.clone());
608
609 let missing = ServiceAccountTokenCredentialProvider::from_provider(FixedProvider {
610 credential: None,
611 });
612 assert!(missing.provide_credential(&ctx).await?.is_none());
613
614 let wrong_variant = ServiceAccountTokenCredentialProvider::from_provider(FixedProvider {
615 credential: Some(Credential::with_token(Token {
616 access_token: "source-token".to_string(),
617 expires_at: None,
618 })),
619 });
620 let err = wrong_variant
621 .provide_credential(&ctx)
622 .await
623 .expect_err("token-only source must be rejected");
624 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
625
626 let invalid_key = ServiceAccountTokenCredentialProvider::new(ServiceAccount {
627 private_key: "private-key-secret".to_string(),
628 client_email: "provider@example.iam.gserviceaccount.com".to_string(),
629 });
630 let err = invalid_key
631 .provide_credential(&ctx)
632 .await
633 .expect_err("invalid private key must be rejected");
634 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
635 assert_eq!(http.requests().len(), 0);
636 Ok(())
637 }
638
639 #[tokio::test]
640 async fn accepts_file_and_default_service_account_sources() -> Result<()> {
641 let service_account = service_account();
642 let content = service_account_json(&service_account).into_bytes();
643
644 let file_http = MockHttpSend::new([oauth_success("file-token", 3600)]);
645 let file_context = Context::new()
646 .with_file_read(FixedFileRead::new(content.clone()))
647 .with_http_send(file_http);
648 let file_output = ServiceAccountTokenCredentialProvider::from_provider(
649 FileCredentialProvider::new("/service-account.json"),
650 )
651 .provide_credential(&file_context)
652 .await?
653 .expect("file source must return a credential");
654 assert_eq!(
655 file_output
656 .token
657 .expect("file source token must be present")
658 .access_token,
659 "file-token"
660 );
661
662 let default_http = MockHttpSend::new([oauth_success("default-token", 3600)]);
663 let default_context = Context::new()
664 .with_file_read(FixedFileRead::new(content))
665 .with_http_send(default_http)
666 .with_env(StaticEnv {
667 home_dir: None,
668 envs: HashMap::from([(
669 crate::constants::GOOGLE_APPLICATION_CREDENTIALS.to_string(),
670 "/service-account.json".to_string(),
671 )]),
672 });
673 let default_output =
674 ServiceAccountTokenCredentialProvider::from_provider(DefaultCredentialProvider::new())
675 .provide_credential(&default_context)
676 .await?
677 .expect("default source must return a credential");
678 assert_eq!(
679 default_output
680 .token
681 .expect("default source token must be present")
682 .access_token,
683 "default-token"
684 );
685 Ok(())
686 }
687
688 #[tokio::test]
689 async fn rejects_token_that_is_too_short_after_oauth_io() {
690 let http = MockHttpSend::new([oauth_success("short-token", 5)]);
691 let err = ServiceAccountTokenCredentialProvider::new(service_account())
692 .provide_credential(&Context::new().with_http_send(http.clone()))
693 .await
694 .expect_err("short OAuth token must be rejected");
695
696 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
697 assert_eq!(http.requests().len(), 1);
698 }
699
700 #[test]
701 fn expiration_is_anchored_and_checked_without_panicking() {
702 let started_at: Timestamp = "2030-01-01T00:00:00Z"
703 .parse()
704 .expect("timestamp must parse");
705 let token = parse_token_response(
706 br#"{"access_token":"source-token","expires_in":3600}"#,
707 started_at,
708 )
709 .expect("response must parse");
710 assert_eq!(
711 token.expires_at,
712 Some(
713 "2030-01-01T01:00:00Z"
714 .parse()
715 .expect("timestamp must parse")
716 )
717 );
718
719 let near_limit: Timestamp = "9999-12-30T21:59:59Z"
720 .parse()
721 .expect("timestamp must parse");
722 let err = parse_token_response(
723 br#"{"access_token":"source-token","expires_in":2}"#,
724 near_limit,
725 )
726 .expect_err("overflow must be rejected");
727 assert_eq!(err.kind(), ErrorKind::Unexpected);
728
729 for malformed in [
730 br#"{"access_token":"source-token"}"#.as_slice(),
731 br#"{"access_token":"","expires_in":3600}"#.as_slice(),
732 br#"{"access_token":"source-token","expires_in":0}"#.as_slice(),
733 ] {
734 assert!(parse_token_response(malformed, started_at).is_err());
735 }
736 }
737
738 #[tokio::test]
739 async fn redacts_provider_debug_and_oauth_errors() {
740 let service_account = service_account();
741 let source_json = service_account_json(&service_account);
742 let provider = ServiceAccountTokenCredentialProvider::from_provider(
743 StaticCredentialProvider::new(source_json),
744 );
745 let debug = format!("{provider:?}");
746 assert!(!debug.contains(&service_account.private_key));
747 assert!(!debug.contains("BEGIN PRIVATE KEY"));
748
749 let body = br#"{
750 "error":"invalid_grant",
751 "error_description":"private-key-secret assertion-secret access-token-secret"
752 }"#;
753 let http = MockHttpSend::new([response(http::StatusCode::BAD_REQUEST, body.as_slice())]);
754 let err = provider
755 .provide_credential(&Context::new().with_http_send(http))
756 .await
757 .expect_err("OAuth error must be returned");
758 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
759 let rendered = format!("{err:?}");
760 assert!(rendered.contains("oauth_error: invalid_grant"));
761 assert!(!rendered.contains("private-key-secret"));
762 assert!(!rendered.contains("assertion-secret"));
763 assert!(!rendered.contains("access-token-secret"));
764 }
765
766 #[tokio::test]
767 async fn composes_static_service_account_with_server_side_cab() -> Result<()> {
768 let http = MockHttpSend::new([
769 oauth_success("source-token", 3600),
770 cab_success("downscoped-token", 600),
771 ]);
772 let service_account = service_account();
773 let source = ServiceAccountTokenCredentialProvider::from_provider(
774 StaticCredentialProvider::new(service_account_json(&service_account)),
775 );
776 let grant = CredentialAccessBoundaryGrant::for_object_prefix(
777 "example-bucket",
778 "customer-a/",
779 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
780 );
781 let output = Granter::new(
782 Context::new().with_http_send(http.clone()),
783 source,
784 ServerSideCredentialAccessBoundaryGranter::new(grant),
785 )
786 .grant(None)
787 .await?;
788
789 assert!(output.service_account.is_none());
790 let token = output.token.expect("downscoped token must be present");
791 assert_eq!(token.access_token, "downscoped-token");
792 assert!(token.expires_at.is_some());
793
794 let requests = http.requests();
795 assert_eq!(requests.len(), 2);
796 assert_eq!(requests[0].uri, OAUTH_TOKEN_ENDPOINT);
797 assert_eq!(requests[1].uri, "https://sts.googleapis.com/v1/token");
798 assert_eq!(
799 form_fields(&requests[1].body)["subject_token"],
800 "source-token"
801 );
802 Ok(())
803 }
804}