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