1use 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
37pub const CLOUD_PLATFORM_SCOPE: &str = DEFAULT_SCOPE;
39
40pub const IAM_CREDENTIALS_ENDPOINT: &str = "https://iamcredentials.googleapis.com";
42
43pub const STS_TOKEN_ENDPOINT: &str = "https://sts.googleapis.com/v1/token";
45
46const SERVICE_ACCOUNT_TOKEN_LIFETIME: u64 = 3600;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GcpToken {
53 pub credential: GcpCredential,
55 pub expiration: DateTime<Utc>,
57}
58
59#[derive(Debug, Clone, Default)]
61pub struct GcpTokenOptions {
62 pub endpoint: Option<String>,
66 pub client_options: ClientOptions,
68 pub retry: RetryConfig,
70}
71
72pub 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
111pub(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
159pub 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
216pub 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
267fn 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 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 r#"{"access_token":"downscoped","token_type":"Bearer"}"#,
531 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}