Skip to main content

reqsign_google/provide_credential/
service_account_token.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, 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
95/// Exchanges a service-account credential for an OAuth access token.
96///
97/// The provider returns a token-only [`Credential`] with a non-optional absolute
98/// expiration and preserves the source service account email as signer identity.
99/// This makes service-account JSON loaded by providers such as
100/// [`super::FileCredentialProvider`] usable as the source of a Google credential
101/// granter without requiring the host application to implement the JWT bearer
102/// exchange.
103///
104/// The provider performs an OAuth exchange on every call. Cache and refresh
105/// behavior remains owned by the outer [`reqsign_core::Signer`] or
106/// [`reqsign_core::Granter`]. It is not inserted into the default credential
107/// provider chain.
108///
109/// # Example
110///
111/// ```no_run
112/// use reqsign_core::{Context, Granter};
113/// use reqsign_google::{
114///     CredentialAccessBoundaryGrant, CredentialAccessBoundaryPermissions,
115///     FileCredentialProvider, ServerSideCredentialAccessBoundaryGranter,
116///     ServiceAccountTokenCredentialProvider,
117/// };
118///
119/// # async fn example(context: Context) -> reqsign_core::Result<()> {
120/// let source = ServiceAccountTokenCredentialProvider::from_provider(
121///     FileCredentialProvider::new("/path/to/service-account.json"),
122/// );
123/// let grant = CredentialAccessBoundaryGrant::for_object_prefix(
124///     "example-bucket",
125///     "customer-a/",
126///     CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
127/// );
128/// let credential = Granter::new(
129///     context,
130///     source,
131///     ServerSideCredentialAccessBoundaryGranter::new(grant),
132/// )
133/// .grant(None)
134/// .await?;
135/// # let _ = credential;
136/// # Ok(())
137/// # }
138/// ```
139pub 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    /// Create a provider bound to one service account.
154    pub fn new(service_account: ServiceAccount) -> Self {
155        Self {
156            source: Source::ServiceAccount(service_account),
157            scope: None,
158        }
159    }
160
161    /// Create a provider that loads its service account from another provider.
162    ///
163    /// `None` from the source provider is preserved. A returned credential must
164    /// contain a valid service account; other credential variants are rejected
165    /// before the OAuth request is sent.
166    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    /// Set the OAuth scope.
176    ///
177    /// This value takes precedence over `GOOGLE_SCOPE`. When neither is set,
178    /// the Google Cloud platform scope is used.
179    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}