Skip to main content

olai_http/gcp/
token.rs

1//! One-off GCP token calls with wall-clock expiries.
2//!
3//! The [`GcpCredentialProvider`]s of [`GoogleBuilder`](super::GoogleBuilder) cache
4//! their tokens and hide when they expire. A credential vending server needs that
5//! expiry: it hands tokens to clients, and a downscoped token cannot outlive the token
6//! it was exchanged from. These functions make one call each and return a
7//! [`GcpToken`] with the expiry Google reports:
8//!
9//! - [`impersonate_access_token`]: an access token for a service account, requested
10//!   by another identity (`generateAccessToken`), for a chosen lifetime.
11//! - [`service_account_access_token`]: an OAuth access token for a service-account
12//!   key (the JWT-bearer grant). Unlike the self-signed JWTs `GoogleBuilder` signs
13//!   with a key, these can be exchanged at STS.
14//! - [`downscope_token`]: an access token exchanged at STS for one restricted by a
15//!   Credential Access Boundary.
16
17use std::time::Duration;
18
19use chrono::{DateTime, TimeDelta, Utc};
20use reqwest::Method;
21use serde::Serialize;
22
23use super::GcpCredentialProvider;
24use super::credential::{
25    DEFAULT_SCOPE, GcpCredential, ImpersonateTokenResponse, TokenResponse, seconds_since_epoch,
26    sign_jwt,
27};
28use crate::bearer::{
29    ACCESS_TOKEN_TYPE, GcpServiceAccount, JWT_BEARER_GRANT, LoadedServiceAccount,
30    TOKEN_EXCHANGE_GRANT,
31};
32use crate::retry::RetryExt;
33use crate::service::make_service;
34use crate::{ClientOptions, Error, Result, RetryConfig};
35
36/// The OAuth scope of all Google Cloud APIs.
37pub const CLOUD_PLATFORM_SCOPE: &str = DEFAULT_SCOPE;
38
39/// Google's IAM Service Account Credentials API.
40pub const IAM_CREDENTIALS_ENDPOINT: &str = "https://iamcredentials.googleapis.com";
41
42/// Google's Security Token Service token endpoint.
43pub const STS_TOKEN_ENDPOINT: &str = "https://sts.googleapis.com/v1/token";
44
45/// The lifetime of an access token from a service-account key; Google's token
46/// endpoint issues no other.
47const SERVICE_ACCOUNT_TOKEN_LIFETIME: u64 = 3600;
48
49/// An access token and when it expires.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct GcpToken {
52    /// The token (`Debug` redacts it).
53    pub credential: GcpCredential,
54    /// When Google says the token expires.
55    pub expiration: DateTime<Utc>,
56}
57
58/// How to make one of this module's calls.
59#[derive(Debug, Clone, Default)]
60pub struct GcpTokenOptions {
61    /// Overrides the endpoint called: the base URL of the IAM Service Account
62    /// Credentials API ([`IAM_CREDENTIALS_ENDPOINT`]), the key's token URI, or the STS
63    /// token URL ([`STS_TOKEN_ENDPOINT`]).
64    pub endpoint: Option<String>,
65    /// HTTP client options.
66    pub client_options: ClientOptions,
67    /// Retries.
68    pub retry: RetryConfig,
69}
70
71/// An access token for the service account `email`, requested with `base`'s
72/// credentials, for `lifetime` (whole seconds; Google allows up to one hour, or twelve
73/// where the organization policy extends it).
74///
75/// `base` must hold `iam.serviceAccounts.getAccessToken` on the account, for example
76/// through `roles/iam.serviceAccountTokenCreator`.
77///
78/// <https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken>
79pub async fn impersonate_access_token(
80    base: &GcpCredentialProvider,
81    email: &str,
82    lifetime: Duration,
83    scopes: &[&str],
84    options: &GcpTokenOptions,
85) -> Result<GcpToken> {
86    if email.is_empty() || email.contains(['/', '?', '#', '%']) {
87        return Err(generic(format!("invalid service account `{email}`")));
88    }
89    let endpoint = options
90        .endpoint
91        .as_deref()
92        .unwrap_or(IAM_CREDENTIALS_ENDPOINT)
93        .trim_end_matches('/');
94    let url = format!("{endpoint}/v1/projects/-/serviceAccounts/{email}:generateAccessToken");
95    let base = base.get_credential().await?;
96
97    #[derive(Serialize)]
98    struct Request<'a> {
99        scope: &'a [&'a str],
100        lifetime: String,
101    }
102    let client = options.client_options.client()?;
103    let service = make_service(client.clone(), None);
104    let response: ImpersonateTokenResponse = client
105        .request(Method::POST, url)
106        .bearer_auth(&base.bearer)
107        .json(&Request {
108            scope: scopes,
109            lifetime: format!("{}s", lifetime.as_secs()),
110        })
111        .retryable(&options.retry, service)
112        .idempotent(true)
113        .send()
114        .await
115        .map_err(|e| e.error())?
116        .json()
117        .await?;
118    let expiration = DateTime::parse_from_rfc3339(&response.expire_time)
119        .map_err(|e| {
120            generic(format!(
121                "invalid `expireTime` `{}`: {e}",
122                response.expire_time
123            ))
124        })?
125        .with_timezone(&Utc);
126    Ok(GcpToken {
127        credential: GcpCredential {
128            bearer: response.access_token,
129        },
130        expiration,
131    })
132}
133
134/// An OAuth access token for `account`, with `scope`, through the JWT-bearer grant at
135/// the key's token URI. It lives one hour.
136///
137/// <https://developers.google.com/identity/protocols/oauth2/service-account#httprest>
138pub async fn service_account_access_token(
139    account: &GcpServiceAccount,
140    scope: &str,
141    options: &GcpTokenOptions,
142) -> Result<GcpToken> {
143    let LoadedServiceAccount {
144        credentials,
145        key,
146        token_uri,
147    } = account.load("access tokens")?;
148    let token_uri = options.endpoint.clone().unwrap_or(token_uri);
149
150    #[derive(Serialize)]
151    struct Claims<'a> {
152        iss: &'a str,
153        scope: &'a str,
154        aud: &'a str,
155        iat: u64,
156        exp: u64,
157    }
158    let iat = seconds_since_epoch();
159    let assertion = sign_jwt(
160        &key,
161        &credentials.private_key_id,
162        &Claims {
163            iss: &credentials.client_email,
164            scope,
165            aud: &token_uri,
166            iat,
167            exp: iat + SERVICE_ACCOUNT_TOKEN_LIFETIME,
168        },
169    )?;
170    let issued = Utc::now();
171    let client = options.client_options.client()?;
172    let service = make_service(client.clone(), None);
173    let response: TokenResponse = client
174        .request(Method::POST, &token_uri)
175        .form(&[("grant_type", JWT_BEARER_GRANT), ("assertion", &assertion)])
176        .retryable(&options.retry, service)
177        .idempotent(true)
178        .send()
179        .await
180        .map_err(|e| e.error())?
181        .json()
182        .await?;
183    Ok(GcpToken {
184        credential: GcpCredential {
185            bearer: response.access_token,
186        },
187        expiration: after(issued, response.expires_in),
188    })
189}
190
191/// Exchanges `source` at STS for a token restricted by `access_boundary`, the
192/// Credential Access Boundary document (`{"accessBoundary": {...}}`).
193///
194/// The downscoped token cannot outlive `source`: its expiry is the one STS reports,
195/// or `source`'s when STS reports none, and never later than `source`'s.
196///
197/// <https://cloud.google.com/iam/docs/downscoping-short-lived-credentials>
198pub async fn downscope_token(
199    source: &GcpToken,
200    access_boundary: &serde_json::Value,
201    options: &GcpTokenOptions,
202) -> Result<GcpToken> {
203    #[derive(serde::Deserialize)]
204    struct Response {
205        access_token: String,
206        expires_in: Option<u64>,
207    }
208
209    let endpoint = options.endpoint.as_deref().unwrap_or(STS_TOKEN_ENDPOINT);
210    let boundary = access_boundary.to_string();
211    let issued = Utc::now();
212    let client = options.client_options.client()?;
213    let service = make_service(client.clone(), None);
214    let response: Response = client
215        .request(Method::POST, endpoint)
216        .form(&[
217            ("grant_type", TOKEN_EXCHANGE_GRANT),
218            ("subject_token_type", ACCESS_TOKEN_TYPE),
219            ("requested_token_type", ACCESS_TOKEN_TYPE),
220            ("subject_token", &source.credential.bearer),
221            ("options", &boundary),
222        ])
223        .retryable(&options.retry, service)
224        .idempotent(true)
225        .send()
226        .await
227        .map_err(|e| e.error())?
228        .json()
229        .await?;
230    let expiration = response
231        .expires_in
232        .map_or(source.expiration, |secs| after(issued, secs))
233        .min(source.expiration);
234    Ok(GcpToken {
235        credential: GcpCredential {
236            bearer: response.access_token,
237        },
238        expiration,
239    })
240}
241
242/// `secs` after `issued`, saturating far in the future.
243fn after(issued: DateTime<Utc>, secs: u64) -> DateTime<Utc> {
244    i64::try_from(secs)
245        .ok()
246        .and_then(TimeDelta::try_seconds)
247        .and_then(|delta| issued.checked_add_signed(delta))
248        .unwrap_or(DateTime::<Utc>::MAX_UTC)
249}
250
251fn generic(message: String) -> Error {
252    Error::Generic {
253        source: message.into(),
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use std::sync::{Arc, OnceLock};
260
261    use base64::Engine;
262    use base64::prelude::BASE64_URL_SAFE_NO_PAD;
263    use mockito::Matcher;
264    use serde_json::json;
265
266    use super::*;
267    use crate::StaticCredentialProvider;
268
269    fn options(server: &mockito::Server, path: &str) -> GcpTokenOptions {
270        GcpTokenOptions {
271            endpoint: Some(format!("{}{path}", server.url())),
272            client_options: ClientOptions::new().with_allow_http(true),
273            retry: RetryConfig {
274                max_retries: 0,
275                ..RetryConfig::default()
276            },
277        }
278    }
279
280    fn token(bearer: &str, expiration: DateTime<Utc>) -> GcpToken {
281        GcpToken {
282            credential: GcpCredential {
283                bearer: bearer.into(),
284            },
285            expiration,
286        }
287    }
288
289    fn base() -> GcpCredentialProvider {
290        Arc::new(StaticCredentialProvider::new(GcpCredential {
291            bearer: "base-token".into(),
292        }))
293    }
294
295    /// A service-account key file around a freshly generated RSA key: no key material
296    /// is checked in.
297    fn service_account(token_uri: &str) -> GcpServiceAccount {
298        static PEM: OnceLock<String> = OnceLock::new();
299        let pem = PEM.get_or_init(|| {
300            use rsa::pkcs8::{EncodePrivateKey, LineEnding};
301            let key = rsa::RsaPrivateKey::new(&mut rand_core::OsRng, 2048).unwrap();
302            key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
303        });
304        GcpServiceAccount::Key(
305            json!({
306                "type": "service_account",
307                "private_key": pem,
308                "private_key_id": "kid",
309                "client_email": "vend@project.iam.gserviceaccount.com",
310                "token_uri": token_uri,
311            })
312            .to_string(),
313        )
314    }
315
316    fn jwt_claims(jwt: &str) -> serde_json::Value {
317        let payload = jwt.split('.').nth(1).unwrap();
318        serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap()
319    }
320
321    #[tokio::test]
322    async fn impersonation_requests_the_lifetime() {
323        let mut server = mockito::Server::new_async().await;
324        let mock = server
325            .mock(
326                "POST",
327                "/v1/projects/-/serviceAccounts/vend@project.iam.gserviceaccount.com:generateAccessToken",
328            )
329            .match_header("authorization", "Bearer base-token")
330            .match_body(Matcher::Json(json!({
331                "scope": [CLOUD_PLATFORM_SCOPE],
332                "lifetime": "1800s",
333            })))
334            .with_body(r#"{"accessToken":"sa-token","expireTime":"2030-01-01T00:30:00Z"}"#)
335            .create_async()
336            .await;
337        let token = impersonate_access_token(
338            &base(),
339            "vend@project.iam.gserviceaccount.com",
340            Duration::from_secs(1800),
341            &[CLOUD_PLATFORM_SCOPE],
342            &options(&server, ""),
343        )
344        .await
345        .unwrap();
346        mock.assert_async().await;
347        assert_eq!(token.credential.bearer, "sa-token");
348        assert_eq!(token.expiration.to_rfc3339(), "2030-01-01T00:30:00+00:00");
349    }
350
351    #[tokio::test]
352    async fn impersonation_rejects_bad_expiries_and_accounts() {
353        let mut server = mockito::Server::new_async().await;
354        let _mock = server
355            .mock("POST", Matcher::Any)
356            .with_body(r#"{"accessToken":"sa-token","expireTime":"soon"}"#)
357            .create_async()
358            .await;
359        let err = impersonate_access_token(
360            &base(),
361            "vend@project.iam.gserviceaccount.com",
362            Duration::from_secs(600),
363            &[CLOUD_PLATFORM_SCOPE],
364            &options(&server, ""),
365        )
366        .await
367        .unwrap_err();
368        assert!(err.to_string().contains("expireTime"), "{err}");
369        assert!(!err.to_string().contains("sa-token"), "{err}");
370
371        for email in ["", "a/b@x", "a@x?y", "a%2F@x"] {
372            let err = impersonate_access_token(
373                &base(),
374                email,
375                Duration::from_secs(600),
376                &[CLOUD_PLATFORM_SCOPE],
377                &options(&server, ""),
378            )
379            .await
380            .unwrap_err();
381            assert!(err.to_string().contains("invalid service account"), "{err}");
382        }
383    }
384
385    #[tokio::test]
386    async fn service_account_key_uses_the_jwt_bearer_grant() {
387        let mut server = mockito::Server::new_async().await;
388        let token_uri = format!("{}/token", server.url());
389        let mock = server
390            .mock("POST", "/token")
391            .match_body(Matcher::UrlEncoded(
392                "grant_type".into(),
393                JWT_BEARER_GRANT.into(),
394            ))
395            .with_body(r#"{"access_token":"oauth-token","expires_in":3599,"token_type":"Bearer"}"#)
396            .create_async()
397            .await;
398        let before = Utc::now();
399        let token = service_account_access_token(
400            &service_account(&token_uri),
401            CLOUD_PLATFORM_SCOPE,
402            &GcpTokenOptions {
403                client_options: ClientOptions::new().with_allow_http(true),
404                ..GcpTokenOptions::default()
405            },
406        )
407        .await
408        .unwrap();
409        mock.assert_async().await;
410        assert_eq!(token.credential.bearer, "oauth-token");
411        assert!(token.expiration >= before + TimeDelta::seconds(3599));
412        assert!(token.expiration <= Utc::now() + TimeDelta::seconds(3599));
413    }
414
415    #[tokio::test]
416    async fn service_account_assertion_claims() {
417        let mut server = mockito::Server::new_async().await;
418        let token_uri = format!("{}/token", server.url());
419        let captured = Arc::new(std::sync::Mutex::new(String::new()));
420        let sink = Arc::clone(&captured);
421        let _mock = server
422            .mock("POST", "/token")
423            .with_body_from_request(move |request| {
424                *sink.lock().unwrap() = String::from_utf8(request.body().unwrap().clone()).unwrap();
425                br#"{"access_token":"oauth-token","expires_in":3600}"#.to_vec()
426            })
427            .create_async()
428            .await;
429        service_account_access_token(
430            &service_account(&token_uri),
431            CLOUD_PLATFORM_SCOPE,
432            &GcpTokenOptions {
433                client_options: ClientOptions::new().with_allow_http(true),
434                ..GcpTokenOptions::default()
435            },
436        )
437        .await
438        .unwrap();
439        let body = captured.lock().unwrap().clone();
440        let assertion = url::form_urlencoded::parse(body.as_bytes())
441            .find(|(k, _)| k == "assertion")
442            .unwrap()
443            .1
444            .into_owned();
445        let claims = jwt_claims(&assertion);
446        assert_eq!(claims["iss"], "vend@project.iam.gserviceaccount.com");
447        assert_eq!(claims["aud"], token_uri);
448        assert_eq!(claims["scope"], CLOUD_PLATFORM_SCOPE);
449        assert_eq!(
450            claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(),
451            SERVICE_ACCOUNT_TOKEN_LIFETIME
452        );
453    }
454
455    #[tokio::test]
456    async fn service_account_rejects_user_credentials() {
457        let key = r#"{"type":"authorized_user","client_id":"id","client_secret":"secret","refresh_token":"refresh"}"#;
458        let err = service_account_access_token(
459            &GcpServiceAccount::Key(key.into()),
460            CLOUD_PLATFORM_SCOPE,
461            &GcpTokenOptions::default(),
462        )
463        .await
464        .unwrap_err();
465        assert!(err.to_string().contains("`authorized_user`"), "{err}");
466        assert!(!err.to_string().contains("secret"), "{err}");
467    }
468
469    #[tokio::test]
470    async fn downscope_exchanges_with_the_boundary() {
471        let boundary = json!({ "accessBoundary": { "accessBoundaryRules": [{
472            "availableResource": "//storage.googleapis.com/projects/_/buckets/b",
473            "availablePermissions": ["inRole:roles/storage.objectViewer"],
474        }]}});
475        let mut server = mockito::Server::new_async().await;
476        let mock = server
477            .mock("POST", "/v1/token")
478            .match_body(Matcher::AllOf(vec![
479                Matcher::UrlEncoded("grant_type".into(), TOKEN_EXCHANGE_GRANT.into()),
480                Matcher::UrlEncoded("subject_token_type".into(), ACCESS_TOKEN_TYPE.into()),
481                Matcher::UrlEncoded("requested_token_type".into(), ACCESS_TOKEN_TYPE.into()),
482                Matcher::UrlEncoded("subject_token".into(), "source-token".into()),
483                Matcher::UrlEncoded("options".into(), boundary.to_string()),
484            ]))
485            .with_body(r#"{"access_token":"downscoped","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":600}"#)
486            .create_async()
487            .await;
488        let source = token("source-token", Utc::now() + TimeDelta::hours(1));
489        let before = Utc::now();
490        let downscoped = downscope_token(&source, &boundary, &options(&server, "/v1/token"))
491            .await
492            .unwrap();
493        mock.assert_async().await;
494        assert_eq!(downscoped.credential.bearer, "downscoped");
495        assert!(downscoped.expiration >= before + TimeDelta::seconds(600));
496        assert!(downscoped.expiration < source.expiration);
497    }
498
499    #[tokio::test]
500    async fn downscoped_tokens_never_outlive_their_source() {
501        let mut server = mockito::Server::new_async().await;
502        let source = token("source-token", Utc::now() + TimeDelta::minutes(5));
503        for body in [
504            // No expiry reported: the source's.
505            r#"{"access_token":"downscoped","token_type":"Bearer"}"#,
506            // Longer than the source: clamped to it.
507            r#"{"access_token":"downscoped","expires_in":3600}"#,
508        ] {
509            let mock = server
510                .mock("POST", "/v1/token")
511                .with_body(body)
512                .create_async()
513                .await;
514            let downscoped = downscope_token(&source, &json!({}), &options(&server, "/v1/token"))
515                .await
516                .unwrap();
517            mock.assert_async().await;
518            mock.remove_async().await;
519            assert_eq!(downscoped.expiration, source.expiration, "{body}");
520        }
521    }
522
523    #[tokio::test]
524    async fn downscope_errors_do_not_leak_the_source() {
525        let mut server = mockito::Server::new_async().await;
526        let _mock = server
527            .mock("POST", "/v1/token")
528            .with_status(400)
529            .with_body(r#"{"error":"invalid_grant"}"#)
530            .create_async()
531            .await;
532        let source = token("source-token-secret", Utc::now() + TimeDelta::hours(1));
533        let err = downscope_token(&source, &json!({}), &options(&server, "/v1/token"))
534            .await
535            .unwrap_err();
536        assert!(
537            !format!("{err} {err:?}").contains("source-token-secret"),
538            "{err}"
539        );
540        assert!(!format!("{source:?}").contains("source-token-secret"));
541    }
542}