1use std::sync::{Arc, Mutex, PoisonError, RwLock};
2
3use google_cloud_auth::credentials::idtoken::{self, IDTokenCredentials};
4use google_cloud_auth::credentials::service_account::{self, AccessSpecifier};
5use google_cloud_auth::credentials::{
6 external_account, Builder as CredentialsBuilder, CacheableResource, Credentials,
7 CredentialsProvider, EntityTag,
8};
9use google_cloud_auth::errors::CredentialsError;
10use hyper::header::{Entry, HeaderMap, HeaderValue, AUTHORIZATION};
11use hyper::http::Extensions;
12use serde_json::Value;
13
14use crate::adc::{ensure_crypto_provider, AdcFile, AdcKind};
15use crate::error::{ErrorKind, IdTokenUnsupportedCredentials};
16use crate::{IdTokenAudience, ServiceAccountEmail, GCP_DEFAULT_SCOPES};
17
18#[derive(Debug)]
35pub struct GoogleAuthHeaders {
36 credentials: Credentials,
37 cached: RwLock<Option<CachedHeaders>>,
39}
40
41#[derive(Debug, Clone)]
42struct CachedHeaders {
43 entity_tag: EntityTag,
44 headers: Arc<HeaderMap>,
45}
46
47impl GoogleAuthHeaders {
48 pub async fn id_token_from_adc(audience: &IdTokenAudience) -> crate::error::Result<Self> {
69 Self::id_token_from_adc_file(AdcFile::load(), audience)
70 }
71
72 fn id_token_from_adc_file(
73 adc: Option<AdcFile>,
74 audience: &IdTokenAudience,
75 ) -> crate::error::Result<Self> {
76 let unsupported = match adc.as_ref().map(AdcFile::kind) {
77 Some(AdcKind::AuthorizedUser) => IdTokenUnsupportedCredentials::AuthorizedUser,
78 Some(AdcKind::ExternalAccountAuthorizedUser) => {
79 IdTokenUnsupportedCredentials::ExternalAccountAuthorizedUser
80 }
81 Some(AdcKind::GdchServiceAccount) => IdTokenUnsupportedCredentials::GdchServiceAccount,
82 Some(AdcKind::ExternalAccount(config)) => {
83 let federation = ImpersonatedFederation::try_from(config)?;
84 let source = external_account::Builder::new(federation.source)
85 .with_scopes(GCP_DEFAULT_SCOPES.iter())
86 .build()?;
87 return Self::impersonated(audience, &federation.service_account, source);
88 }
89 Some(AdcKind::Other) | None => {
90 if let Some(adc) = &adc {
91 adc.ensure_signing_provider()?;
92 }
93 return Ok(Self::from(
94 idtoken::Builder::new(audience.as_str())
95 .with_include_email()
96 .build()?,
97 ));
98 }
99 };
100 Err(ErrorKind::IdTokenNeedsImpersonation(unsupported).into())
101 }
102
103 pub async fn id_token_impersonating(
113 audience: &IdTokenAudience,
114 service_account: &ServiceAccountEmail,
115 source_credentials: Credentials,
116 ) -> crate::error::Result<Self> {
117 Self::impersonated(audience, service_account, source_credentials)
118 }
119
120 fn impersonated(
121 audience: &IdTokenAudience,
122 service_account: &ServiceAccountEmail,
123 source_credentials: Credentials,
124 ) -> crate::error::Result<Self> {
125 let credentials = idtoken::impersonated::Builder::from_source_credentials(
126 audience.as_str(),
127 service_account.to_string(),
128 source_credentials,
129 )
130 .with_include_email()
131 .build()?;
132 Ok(Self::from(credentials))
133 }
134
135 pub async fn from_adc() -> crate::error::Result<Self> {
139 Self::from_adc_with_scopes(GCP_DEFAULT_SCOPES.clone()).await
140 }
141
142 pub async fn from_adc_with_scopes(scopes: Vec<String>) -> crate::error::Result<Self> {
150 if let Some(adc) = AdcFile::load() {
151 adc.ensure_signing_provider()?;
152 }
153 let credentials = CredentialsBuilder::default().with_scopes(scopes).build()?;
154 Ok(Self::from(credentials))
155 }
156
157 pub async fn from_service_account_key(
165 key: Value,
166 scopes: Vec<String>,
167 ) -> crate::error::Result<Self> {
168 ensure_crypto_provider()?;
169 let credentials = service_account::Builder::new(key)
170 .with_access_specifier(AccessSpecifier::from_scopes(scopes))
171 .build()?;
172 Ok(Self::from(credentials))
173 }
174
175 pub async fn headers(&self) -> crate::error::Result<Arc<HeaderMap>> {
177 let cached = self
178 .cached
179 .read()
180 .unwrap_or_else(PoisonError::into_inner)
181 .clone();
182 let mut extensions = Extensions::new();
183 if let Some(cached) = &cached {
184 extensions.insert(cached.entity_tag.clone());
185 }
186 match self.credentials.headers(extensions).await? {
187 CacheableResource::NotModified => {
188 cached.map(|cached| cached.headers).ok_or_else(|| {
189 CredentialsError::from_msg(
190 false,
191 "the credentials reported no change to headers they never served",
192 )
193 .into()
194 })
195 }
196 CacheableResource::New {
197 entity_tag,
198 mut data,
199 } => {
200 if let Entry::Occupied(mut authorization) = data.entry(AUTHORIZATION) {
201 authorization
202 .iter_mut()
203 .for_each(|value| value.set_sensitive(true));
204 }
205 let headers = Arc::new(data);
206 *self.cached.write().unwrap_or_else(PoisonError::into_inner) =
207 Some(CachedHeaders {
208 entity_tag,
209 headers: Arc::clone(&headers),
210 });
211 Ok(headers)
212 }
213 }
214 }
215}
216
217impl From<Credentials> for GoogleAuthHeaders {
225 fn from(credentials: Credentials) -> Self {
226 Self {
227 credentials,
228 cached: RwLock::new(None),
229 }
230 }
231}
232
233impl From<IDTokenCredentials> for GoogleAuthHeaders {
242 fn from(credentials: IDTokenCredentials) -> Self {
243 Self::from(Credentials::from(IdTokenHeaders::from(credentials)))
244 }
245}
246
247#[derive(Debug)]
254struct ImpersonatedFederation {
255 service_account: ServiceAccountEmail,
256 source: Value,
258}
259
260impl TryFrom<&Value> for ImpersonatedFederation {
261 type Error = crate::error::Error;
262
263 fn try_from(config: &Value) -> crate::error::Result<Self> {
264 let Some(impersonation_url) = config
265 .get("service_account_impersonation_url")
266 .and_then(Value::as_str)
267 else {
268 return Err(ErrorKind::IdTokenNeedsImpersonation(
269 IdTokenUnsupportedCredentials::ExternalAccount,
270 )
271 .into());
272 };
273 let service_account = ServiceAccountEmail::from_impersonation_url(impersonation_url)?;
274 let mut source = config.clone();
275 if let Some(fields) = source.as_object_mut() {
276 fields.remove("service_account_impersonation_url");
277 }
278 Ok(Self {
279 service_account,
280 source,
281 })
282 }
283}
284
285#[derive(Debug)]
289struct IdTokenHeaders {
290 credentials: IDTokenCredentials,
291 current: Mutex<Option<TaggedAuthorization>>,
293}
294
295#[derive(Debug, Clone)]
296struct TaggedAuthorization {
297 entity_tag: EntityTag,
298 authorization: HeaderValue,
299}
300
301impl From<IDTokenCredentials> for IdTokenHeaders {
302 fn from(credentials: IDTokenCredentials) -> Self {
303 Self {
304 credentials,
305 current: Mutex::new(None),
306 }
307 }
308}
309
310impl IdTokenHeaders {
311 fn tagged(&self, id_token: &str) -> Result<TaggedAuthorization, CredentialsError> {
314 let mut current = self.current.lock().unwrap_or_else(PoisonError::into_inner);
315 if let Some(tagged) = current.as_ref().filter(|tagged| tagged.is_for(id_token)) {
316 return Ok(tagged.clone());
317 }
318 let mut authorization = HeaderValue::from_str(&format!("Bearer {id_token}"))
319 .map_err(|error| CredentialsError::from_source(false, error))?;
320 authorization.set_sensitive(true);
321 let tagged = TaggedAuthorization {
322 entity_tag: EntityTag::new(),
323 authorization,
324 };
325 *current = Some(tagged.clone());
326 Ok(tagged)
327 }
328}
329
330impl TaggedAuthorization {
331 fn is_for(&self, id_token: &str) -> bool {
332 self.authorization.as_bytes().strip_prefix(b"Bearer ") == Some(id_token.as_bytes())
333 }
334}
335
336impl CredentialsProvider for IdTokenHeaders {
337 async fn headers(
338 &self,
339 extensions: Extensions,
340 ) -> Result<CacheableResource<HeaderMap>, CredentialsError> {
341 let id_token = self.credentials.id_token().await?;
342 let tagged = self.tagged(&id_token)?;
343 if extensions.get::<EntityTag>() == Some(&tagged.entity_tag) {
344 return Ok(CacheableResource::NotModified);
345 }
346 let mut headers = HeaderMap::new();
347 headers.insert(AUTHORIZATION, tagged.authorization);
348 Ok(CacheableResource::New {
349 entity_tag: tagged.entity_tag,
350 data: headers,
351 })
352 }
353
354 async fn universe_domain(&self) -> Option<String> {
355 None
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::error::{ErrorKind, IdTokenUnsupportedCredentials};
363 use google_cloud_auth::credentials::idtoken::IDTokenCredentialsProvider;
364 use serde_json::json;
365 use std::sync::Mutex;
366
367 const AUDIENCE: &str = "https://orders-abc123-ew.a.run.app";
368 const INVOKER: &str = "invoker@my-project.iam.gserviceaccount.com";
369
370 #[derive(Debug, Clone)]
372 struct ScriptedIdToken {
373 token: Arc<Mutex<String>>,
374 }
375
376 impl ScriptedIdToken {
377 fn new(token: &str) -> Self {
378 Self {
379 token: Arc::new(Mutex::new(token.to_string())),
380 }
381 }
382
383 fn rotate(&self, token: &str) {
384 *self.token.lock().unwrap() = token.to_string();
385 }
386 }
387
388 impl IDTokenCredentialsProvider for ScriptedIdToken {
389 async fn id_token(&self) -> Result<String, CredentialsError> {
390 Ok(self.token.lock().unwrap().clone())
391 }
392 }
393
394 #[tokio::test]
395 async fn id_token_is_served_as_a_sensitive_bearer_header() {
396 let auth_headers = GoogleAuthHeaders::from(IDTokenCredentials::from(ScriptedIdToken::new(
397 "minted-id-token",
398 )));
399
400 let headers = auth_headers.headers().await.unwrap();
401
402 assert_eq!(headers[AUTHORIZATION], "Bearer minted-id-token");
403 assert!(headers[AUTHORIZATION].is_sensitive());
404 }
405
406 #[tokio::test]
407 async fn id_token_headers_keep_their_entity_tag_until_the_token_changes() {
408 let id_token = ScriptedIdToken::new("first-id-token");
409 let credentials = Credentials::from(IdTokenHeaders::from(IDTokenCredentials::from(
410 id_token.clone(),
411 )));
412
413 let CacheableResource::New { entity_tag, .. } =
414 credentials.headers(Extensions::new()).await.unwrap()
415 else {
416 panic!("expected the first headers in full");
417 };
418 let mut holding_first = Extensions::new();
419 holding_first.insert(entity_tag.clone());
420 assert_eq!(
421 credentials.headers(holding_first.clone()).await.unwrap(),
422 CacheableResource::NotModified
423 );
424
425 id_token.rotate("second-id-token");
426 match credentials.headers(holding_first).await.unwrap() {
427 CacheableResource::New {
428 entity_tag: rotated,
429 data,
430 } => {
431 assert_ne!(rotated, entity_tag);
432 assert_eq!(data[AUTHORIZATION], "Bearer second-id-token");
433 }
434 CacheableResource::NotModified => panic!("expected the rotated token"),
435 }
436 }
437
438 fn federation_config() -> serde_json::Value {
439 json!({
440 "type": "external_account",
441 "audience": "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/github/providers/github",
442 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
443 "token_url": "https://sts.googleapis.com/v1/token",
444 "credential_source": { "file": "/nonexistent/oidc-token" },
445 })
446 }
447
448 fn id_token_error(adc: serde_json::Value) -> ErrorKind {
449 GoogleAuthHeaders::id_token_from_adc_file(
450 Some(AdcFile::from(adc)),
451 &IdTokenAudience::new(AUDIENCE),
452 )
453 .unwrap_err()
454 .into_kind()
455 }
456
457 #[tokio::test]
458 async fn authorized_user_credentials_need_impersonation() {
459 let user = json!({
460 "type": "authorized_user",
461 "client_id": "client-id",
462 "client_secret": "client-secret",
463 "refresh_token": "refresh-token",
464 });
465
466 assert!(matches!(
467 id_token_error(user),
468 ErrorKind::IdTokenNeedsImpersonation(IdTokenUnsupportedCredentials::AuthorizedUser)
469 ));
470 }
471
472 #[tokio::test]
473 async fn external_account_credentials_without_impersonation_need_impersonation() {
474 assert!(matches!(
475 id_token_error(federation_config()),
476 ErrorKind::IdTokenNeedsImpersonation(IdTokenUnsupportedCredentials::ExternalAccount)
477 ));
478 }
479
480 #[tokio::test]
481 async fn external_account_authorized_user_credentials_need_impersonation() {
482 let workforce_user = json!({
483 "type": "external_account_authorized_user",
484 "audience": "//iam.googleapis.com/locations/global/workforcePools/staff/providers/okta",
485 "refresh_token": "refresh-token",
486 "token_url": "https://sts.googleapis.com/v1/oauthtoken",
487 });
488
489 assert!(matches!(
490 id_token_error(workforce_user),
491 ErrorKind::IdTokenNeedsImpersonation(
492 IdTokenUnsupportedCredentials::ExternalAccountAuthorizedUser
493 )
494 ));
495 }
496
497 #[tokio::test]
498 async fn gdch_service_account_credentials_cannot_mint_id_tokens() {
499 let gdch = json!({
500 "type": "gdch_service_account",
501 "project": "orders",
502 "name": "invoker",
503 "token_uri": "https://service-identity.gdch.example/authenticate",
504 });
505
506 assert!(matches!(
507 id_token_error(gdch),
508 ErrorKind::IdTokenNeedsImpersonation(IdTokenUnsupportedCredentials::GdchServiceAccount)
509 ));
510 }
511
512 fn impersonating_federation_config() -> serde_json::Value {
513 let mut config = federation_config();
514 config["service_account_impersonation_url"] = json!(format!(
515 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{INVOKER}:generateAccessToken"
516 ));
517 config
518 }
519
520 #[test]
521 fn impersonated_federation_mints_with_the_federated_token_as_source() {
522 let federation =
523 ImpersonatedFederation::try_from(&impersonating_federation_config()).unwrap();
524
525 assert_eq!(
526 federation.service_account,
527 ServiceAccountEmail::new(INVOKER)
528 );
529 assert_eq!(federation.source, federation_config());
530 }
531
532 #[tokio::test]
533 async fn external_account_credentials_with_impersonation_build_id_token_credentials() {
534 GoogleAuthHeaders::id_token_from_adc_file(
535 Some(AdcFile::from(impersonating_federation_config())),
536 &IdTokenAudience::new(AUDIENCE),
537 )
538 .unwrap();
539 }
540
541 fn install_crypto_provider() {
544 #[cfg(not(feature = "auth-default-crypto"))]
545 let _ = rustls::crypto::ring::default_provider().install_default();
546 }
547
548 fn service_account_key() -> serde_json::Value {
549 json!({
550 "type": "service_account",
551 "project_id": "orders",
552 "private_key_id": "orders-key",
553 "private_key": crate::test_support::TEST_RSA_PRIVATE_KEY,
554 "client_email": INVOKER,
555 })
556 }
557
558 async fn assert_signed_sensitive_bearer(auth_headers: GoogleAuthHeaders) {
559 let headers = auth_headers.headers().await.unwrap();
560
561 let authorization = &headers[AUTHORIZATION];
562 assert!(authorization.as_bytes().starts_with(b"Bearer ey"));
563 assert!(authorization.is_sensitive());
564 }
565
566 #[tokio::test]
567 async fn service_account_key_signs_a_sensitive_bearer_header() {
568 install_crypto_provider();
569 let credentials =
570 google_cloud_auth::credentials::service_account::Builder::new(service_account_key())
571 .build()
572 .unwrap();
573
574 assert_signed_sensitive_bearer(GoogleAuthHeaders::from(credentials)).await;
575 }
576
577 #[tokio::test]
578 async fn service_account_key_constructor_signs_a_sensitive_bearer_header() {
579 install_crypto_provider();
580
581 let auth_headers = GoogleAuthHeaders::from_service_account_key(
582 service_account_key(),
583 GCP_DEFAULT_SCOPES.clone(),
584 )
585 .await;
586
587 assert_signed_sensitive_bearer(auth_headers.unwrap()).await;
588 }
589
590 #[tokio::test]
593 async fn service_account_key_as_adc_signs_a_sensitive_bearer_header() {
594 install_crypto_provider();
595 let key_file = std::env::temp_dir().join(format!(
596 "gcloud-sdk-adc-access-tokens-{}.json",
597 std::process::id()
598 ));
599 std::fs::write(&key_file, service_account_key().to_string()).unwrap();
600 std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &key_file);
601
602 let auth_headers = GoogleAuthHeaders::from_adc().await;
603
604 std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS");
605 std::fs::remove_file(key_file).unwrap();
606 assert_signed_sensitive_bearer(auth_headers.unwrap()).await;
607 }
608}