1use 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
36pub const CLOUD_PLATFORM_SCOPE: &str = DEFAULT_SCOPE;
38
39pub const IAM_CREDENTIALS_ENDPOINT: &str = "https://iamcredentials.googleapis.com";
41
42pub const STS_TOKEN_ENDPOINT: &str = "https://sts.googleapis.com/v1/token";
44
45const SERVICE_ACCOUNT_TOKEN_LIFETIME: u64 = 3600;
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct GcpToken {
52 pub credential: GcpCredential,
54 pub expiration: DateTime<Utc>,
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct GcpTokenOptions {
61 pub endpoint: Option<String>,
65 pub client_options: ClientOptions,
67 pub retry: RetryConfig,
69}
70
71pub 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
134pub 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
191pub 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
242fn 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 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 r#"{"access_token":"downscoped","token_type":"Bearer"}"#,
506 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}