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