1use std::fmt;
2
3use async_trait::async_trait;
4use jiff::Timestamp;
5use once_cell::sync::Lazy;
6use secret_vault_value::SecretValue;
7use tracing::*;
8use url::Url;
9
10use crate::error::{ErrorKind, IdTokenUnsupportedCredentials};
11use crate::token_source::credentials::{
12 auth_error_from_response, from_file, from_json, httpc_post, Credentials,
13 ServiceAccountImpersonationSourceCredentials,
14};
15use crate::token_source::metadata::Metadata;
16use crate::token_source::{
17 create_source, find_default_credentials, from_metadata, BoxSource, DefaultCredentials, Source,
18 Token, TokenSourceType,
19};
20use crate::GCP_DEFAULT_SCOPES;
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct IdTokenAudience(String);
27
28impl IdTokenAudience {
29 pub fn new(audience: impl Into<String>) -> Self {
30 Self(audience.into())
31 }
32
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36}
37
38impl fmt::Display for IdTokenAudience {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 f.write_str(&self.0)
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct ServiceAccountEmail(String);
48
49impl ServiceAccountEmail {
50 pub fn new(email: impl Into<String>) -> Self {
51 Self(email.into())
52 }
53}
54
55impl fmt::Display for ServiceAccountEmail {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(&self.0)
58 }
59}
60
61#[derive(Debug)]
69pub struct IdTokenSource {
70 audience: IdTokenAudience,
71 flow: IdTokenFlow,
72}
73
74impl IdTokenSource {
75 pub async fn new(
90 audience: IdTokenAudience,
91 token_source_type: TokenSourceType,
92 ) -> crate::error::Result<Self> {
93 let scopes = GCP_DEFAULT_SCOPES.as_slice();
94 let flow = match token_source_type {
95 TokenSourceType::Default => match find_default_credentials(scopes).await? {
96 DefaultCredentials::File(credentials) => IdTokenFlow::try_from(credentials)?,
97 DefaultCredentials::MetadataServer(metadata) => {
98 IdTokenFlow::MetadataServer(metadata)
99 }
100 },
101 TokenSourceType::Json(json) => {
102 IdTokenFlow::try_from(from_json(json.as_bytes(), scopes)?)?
103 }
104 TokenSourceType::File(path) => IdTokenFlow::try_from(from_file(path, scopes)?)?,
105 TokenSourceType::MetadataServer => {
106 IdTokenFlow::from_metadata_server("default".to_string()).await?
107 }
108 TokenSourceType::MetadataServerWithAccount(account) => {
109 IdTokenFlow::from_metadata_server(account).await?
110 }
111 TokenSourceType::ExternalSource(_) => {
112 return Err(ErrorKind::IdTokenNeedsImpersonation(
113 IdTokenUnsupportedCredentials::ExternalSource,
114 )
115 .into())
116 }
117 };
118 debug!(%audience, ?flow, "Created an ID token source");
119 Ok(Self { audience, flow })
120 }
121
122 pub async fn impersonating(
128 audience: IdTokenAudience,
129 service_account: ServiceAccountEmail,
130 token_source_type: TokenSourceType,
131 ) -> crate::error::Result<Self> {
132 let source = create_source(token_source_type, GCP_DEFAULT_SCOPES.clone()).await?;
133 let flow = IdTokenFlow::Impersonation(Impersonation::new(
134 source,
135 service_account,
136 &IAM_CREDENTIALS_URL,
137 ));
138 debug!(%audience, ?flow, "Created an ID token source");
139 Ok(Self { audience, flow })
140 }
141}
142
143#[async_trait]
144impl Source for IdTokenSource {
145 async fn token(&self) -> crate::error::Result<Token> {
146 let id_token = match &self.flow {
147 IdTokenFlow::ServiceAccountKey(service_account) => {
148 service_account.id_token(&self.audience).await?
149 }
150 IdTokenFlow::MetadataServer(metadata) => {
151 metadata.id_token_with_email(&self.audience).await?
152 }
153 IdTokenFlow::Impersonation(impersonation) => {
154 impersonation.id_token(&self.audience).await?
155 }
156 };
157 let expiry = id_token_expiry(&id_token)?;
158 Ok(Token::new("Bearer".to_string(), id_token, expiry))
159 }
160}
161
162#[derive(serde::Deserialize)]
163struct IdTokenExpiry {
164 #[serde(with = "jiff::fmt::serde::timestamp::second::required")]
165 exp: Timestamp,
166}
167
168fn id_token_expiry(id_token: &SecretValue) -> crate::error::Result<Timestamp> {
171 let claims: IdTokenExpiry =
172 jsonwebtoken::dangerous::insecure_decode_claims(id_token.as_sensitive_bytes())?;
173 Ok(claims.exp)
174}
175
176impl From<IdTokenSource> for BoxSource {
177 fn from(v: IdTokenSource) -> Self {
178 Box::new(v)
179 }
180}
181
182enum IdTokenFlow {
184 ServiceAccountKey(crate::token_source::credentials::ServiceAccount),
185 MetadataServer(Metadata),
186 Impersonation(Impersonation),
187}
188
189impl fmt::Debug for IdTokenFlow {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 match self {
192 Self::ServiceAccountKey(_) => f.write_str("ServiceAccountKey"),
193 Self::MetadataServer(_) => f.write_str("MetadataServer"),
194 Self::Impersonation(impersonation) => f
195 .debug_tuple("Impersonation")
196 .field(&impersonation.service_account)
197 .finish(),
198 }
199 }
200}
201
202impl IdTokenFlow {
203 async fn from_metadata_server(account: String) -> crate::error::Result<Self> {
204 match from_metadata(GCP_DEFAULT_SCOPES.as_slice(), account).await? {
205 Some(metadata) => Ok(Self::MetadataServer(metadata)),
206 None => Err(ErrorKind::TokenSource.into()),
207 }
208 }
209}
210
211impl TryFrom<Credentials> for IdTokenFlow {
212 type Error = crate::error::Error;
213
214 fn try_from(credentials: Credentials) -> Result<Self, Self::Error> {
215 match credentials {
216 Credentials::ServiceAccount(service_account) => {
217 Ok(Self::ServiceAccountKey(service_account))
218 }
219 Credentials::User(_) => Err(ErrorKind::IdTokenNeedsImpersonation(
220 IdTokenUnsupportedCredentials::AuthorizedUser,
221 )
222 .into()),
223 Credentials::ServiceAccountImpersonation(impersonated) => {
227 let service_account = ServiceAccountEmail::from_impersonation_url(
228 &impersonated.service_account_impersonation_url,
229 )?;
230 let source: BoxSource = match impersonated.source_credentials {
231 ServiceAccountImpersonationSourceCredentials::ServiceAccount(
232 mut source_account,
233 ) => {
234 source_account.scopes = GCP_DEFAULT_SCOPES.clone();
235 Credentials::ServiceAccount(source_account).into()
236 }
237 ServiceAccountImpersonationSourceCredentials::User(user) => {
238 Credentials::User(user).into()
239 }
240 };
241 Ok(Self::Impersonation(Impersonation::new(
242 source,
243 service_account,
244 &IAM_CREDENTIALS_URL,
245 )))
246 }
247 Credentials::ExternalAccount(mut external_account) => {
250 let Some(url) = external_account.service_account_impersonation_url.take() else {
251 return Err(ErrorKind::IdTokenNeedsImpersonation(
252 IdTokenUnsupportedCredentials::ExternalAccount,
253 )
254 .into());
255 };
256 let service_account = ServiceAccountEmail::from_impersonation_url(&url)?;
257 external_account.scopes = GCP_DEFAULT_SCOPES.clone();
258 Ok(Self::Impersonation(Impersonation::new(
259 Credentials::ExternalAccount(external_account).into(),
260 service_account,
261 &IAM_CREDENTIALS_URL,
262 )))
263 }
264 }
265 }
266}
267
268static IAM_CREDENTIALS_URL: Lazy<Url> = Lazy::new(|| {
269 Url::parse("https://iamcredentials.googleapis.com").expect("the IAM Credentials URL is valid")
270});
271
272struct Impersonation {
274 source: BoxSource,
276 service_account: ServiceAccountEmail,
277 url: Url,
279}
280
281#[derive(serde::Serialize)]
283#[serde(rename_all = "camelCase")]
284struct GenerateIdTokenRequest<'a> {
285 audience: &'a str,
286 include_email: bool,
287}
288
289#[derive(serde::Deserialize)]
290struct GenerateIdTokenResponse {
291 token: SecretValue,
292}
293
294impl Impersonation {
295 fn new(source: BoxSource, service_account: ServiceAccountEmail, iam_credentials: &Url) -> Self {
296 let mut url = iam_credentials.clone();
297 url.path_segments_mut()
300 .expect("an http(s) URL has path segments")
301 .pop_if_empty()
302 .extend([
303 "v1",
304 "projects",
305 "-",
306 "serviceAccounts",
307 &format!("{}:generateIdToken", service_account.0),
308 ]);
309 Self {
310 source,
311 service_account,
312 url,
313 }
314 }
315
316 async fn id_token(&self, audience: &IdTokenAudience) -> crate::error::Result<SecretValue> {
317 let access_token = self.source.token().await?;
318 let response = httpc_post(self.url.as_str())
319 .header(
320 reqwest::header::AUTHORIZATION,
321 access_token.authorization()?,
322 )
323 .json(&GenerateIdTokenRequest {
324 audience: audience.as_str(),
325 include_email: true,
326 })
327 .send()
328 .await?;
329 if response.status().is_success() {
330 Ok(response.json::<GenerateIdTokenResponse>().await?.token)
331 } else {
332 Err(auth_error_from_response(response).await)
333 }
334 }
335}
336
337impl ServiceAccountEmail {
338 fn from_impersonation_url(url: &str) -> crate::error::Result<Self> {
341 let resource = url.rsplit('/').next().unwrap_or_default();
342 let email = resource.split(':').next().unwrap_or_default();
343 if email.is_empty() {
344 Err(ErrorKind::InvalidImpersonationUrl(url.to_string()).into())
345 } else {
346 Ok(Self::new(email))
347 }
348 }
349}
350
351#[cfg(all(test, any(feature = "jwt-aws-lc-rs", feature = "jwt-rust-crypto")))]
353mod tests {
354 use super::*;
355 use crate::test_support::{signed_jwt, StubResponse, StubServer, TEST_RSA_PRIVATE_KEY};
356 use jiff::SignedDuration;
357
358 const AUDIENCE: &str = "https://orders-abc123-ew.a.run.app";
359 const KEY_ID: &str = "test-key-id";
360 const CLIENT_EMAIL: &str = "caller@my-project.iam.gserviceaccount.com";
361 const TARGET_SERVICE_ACCOUNT: &str = "invoker@my-project.iam.gserviceaccount.com";
362
363 fn id_token_expiring_at(expiry: Timestamp) -> String {
364 signed_jwt(
365 "google-key",
366 &serde_json::json!({
367 "iss": "https://accounts.google.com",
368 "aud": AUDIENCE,
369 "sub": "1234567890",
370 "iat": expiry.as_second() - 3600,
371 "exp": expiry.as_second(),
372 }),
373 )
374 }
375
376 fn service_account_key_json(token_uri: &str) -> String {
377 serde_json::json!({
378 "type": "service_account",
379 "client_email": CLIENT_EMAIL,
380 "private_key_id": KEY_ID,
381 "private_key": TEST_RSA_PRIVATE_KEY,
382 "token_uri": token_uri,
383 })
384 .to_string()
385 }
386
387 fn whole_second(timestamp: Timestamp) -> Timestamp {
388 Timestamp::from_second(timestamp.as_second()).unwrap()
389 }
390
391 #[tokio::test]
392 async fn service_account_key_exchanges_a_target_audience_assertion() {
393 let expiry = whole_second(Timestamp::now() + SignedDuration::from_hours(1));
394 let id_token = id_token_expiring_at(expiry);
395 let token_endpoint = StubServer::start(vec![StubResponse::json(
396 "200 OK",
397 serde_json::json!({ "id_token": id_token }).to_string(),
398 )])
399 .await;
400 let token_uri = format!("{}/token", token_endpoint.url);
401
402 let source = IdTokenSource::new(
403 IdTokenAudience::new(AUDIENCE),
404 TokenSourceType::Json(service_account_key_json(&token_uri)),
405 )
406 .await
407 .unwrap();
408 let token = source.token().await.unwrap();
409
410 assert_eq!(token.token_type, "Bearer");
411 assert_eq!(token.token.as_sensitive_str(), id_token);
412 assert_eq!(token.expiry, expiry);
413
414 let received = token_endpoint.received();
415 assert_eq!(received.len(), 1);
416 assert_eq!(received[0].request_line, "POST /token HTTP/1.1");
417 let form: std::collections::HashMap<String, String> =
418 url::form_urlencoded::parse(received[0].body.as_bytes())
419 .into_owned()
420 .collect();
421 assert_eq!(
422 form["grant_type"],
423 "urn:ietf:params:oauth:grant-type:jwt-bearer"
424 );
425 let assertion = jsonwebtoken::dangerous::insecure_decode::<serde_json::Value>(
426 form["assertion"].as_bytes(),
427 )
428 .unwrap();
429 assert_eq!(assertion.header.kid.as_deref(), Some(KEY_ID));
430 assert_eq!(assertion.claims["iss"], CLIENT_EMAIL);
431 assert_eq!(assertion.claims["aud"], token_uri.as_str());
432 assert_eq!(assertion.claims["target_audience"], AUDIENCE);
433 }
434
435 struct FixedAccessToken;
436
437 #[async_trait]
438 impl Source for FixedAccessToken {
439 async fn token(&self) -> crate::error::Result<Token> {
440 Ok(Token::new(
441 "Bearer".to_string(),
442 SecretValue::from("caller-access-token"),
443 Timestamp::now() + SignedDuration::from_hours(1),
444 ))
445 }
446 }
447
448 #[tokio::test]
449 async fn impersonation_calls_generate_id_token_with_the_access_token() {
450 let expiry = whole_second(Timestamp::now() + SignedDuration::from_hours(1));
451 let id_token = id_token_expiring_at(expiry);
452 let iam = StubServer::start(vec![StubResponse::json(
453 "200 OK",
454 serde_json::json!({ "token": id_token }).to_string(),
455 )])
456 .await;
457
458 let source = IdTokenSource {
459 audience: IdTokenAudience::new(AUDIENCE),
460 flow: IdTokenFlow::Impersonation(Impersonation::new(
461 Box::new(FixedAccessToken),
462 ServiceAccountEmail::new(TARGET_SERVICE_ACCOUNT),
463 &Url::parse(&iam.url).unwrap(),
464 )),
465 };
466 let token = source.token().await.unwrap();
467
468 assert_eq!(token.token.as_sensitive_str(), id_token);
469 assert_eq!(token.expiry, expiry);
470 let received = iam.received();
471 assert_eq!(received.len(), 1);
472 assert_eq!(
473 received[0].request_line,
474 format!(
475 "POST /v1/projects/-/serviceAccounts/{TARGET_SERVICE_ACCOUNT}:generateIdToken HTTP/1.1"
476 )
477 );
478 assert_eq!(
479 received[0].header("authorization"),
480 Some("Bearer caller-access-token")
481 );
482 let body: serde_json::Value = serde_json::from_str(&received[0].body).unwrap();
483 assert_eq!(
484 body,
485 serde_json::json!({ "audience": AUDIENCE, "includeEmail": true })
486 );
487 }
488
489 #[tokio::test]
490 async fn impersonated_credentials_file_impersonates_the_service_account_it_names() {
491 let credentials = serde_json::json!({
492 "type": "impersonated_service_account",
493 "service_account_impersonation_url": format!(
494 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{TARGET_SERVICE_ACCOUNT}:generateAccessToken"
495 ),
496 "source_credentials": {
497 "type": "authorized_user",
498 "client_id": "client-id",
499 "client_secret": "client-secret",
500 "refresh_token": "refresh-token",
501 },
502 });
503
504 let source = IdTokenSource::new(
505 IdTokenAudience::new(AUDIENCE),
506 TokenSourceType::Json(credentials.to_string()),
507 )
508 .await
509 .unwrap();
510
511 match source.flow {
512 IdTokenFlow::Impersonation(impersonation) => {
513 assert_eq!(
514 impersonation.service_account,
515 ServiceAccountEmail::new(TARGET_SERVICE_ACCOUNT)
516 );
517 assert_eq!(
518 impersonation.url.as_str(),
519 format!("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{TARGET_SERVICE_ACCOUNT}:generateIdToken")
520 );
521 }
522 other => panic!("expected impersonation, got {other:?}"),
523 }
524 }
525
526 #[tokio::test]
527 async fn authorized_user_credentials_need_impersonation() {
528 let credentials = serde_json::json!({
529 "type": "authorized_user",
530 "client_id": "client-id",
531 "client_secret": "client-secret",
532 "refresh_token": "refresh-token",
533 });
534
535 let err = IdTokenSource::new(
536 IdTokenAudience::new(AUDIENCE),
537 TokenSourceType::Json(credentials.to_string()),
538 )
539 .await
540 .unwrap_err();
541
542 assert!(matches!(
543 err.kind(),
544 ErrorKind::IdTokenNeedsImpersonation(IdTokenUnsupportedCredentials::AuthorizedUser)
545 ));
546 }
547}