Skip to main content

gcloud_sdk/
auth_headers.rs

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/// The authentication headers of google-cloud-auth credentials, for every request the
18/// tonic and reqwest middleware send: `authorization` with an access token or an ID token,
19/// and whatever else the credentials add, such as `x-goog-user-project` for a quota
20/// project.
21///
22/// The credentials mint, cache and refresh the tokens. A `GoogleAuthHeaders` keeps the
23/// headers of the current token, with `authorization` marked sensitive so that it stays
24/// out of logs, and takes new ones only when the credentials report a new token.
25///
26/// [`from_adc`](Self::from_adc), [`from_adc_with_scopes`](Self::from_adc_with_scopes),
27/// [`id_token_from_adc`](Self::id_token_from_adc) and
28/// [`id_token_impersonating`](Self::id_token_impersonating) build credentials, which
29/// spawns their refresh task on the current Tokio runtime.
30#[derive(Debug)]
31pub struct GoogleAuthHeaders {
32    credentials: Credentials,
33    /// Replaced whole under the lock, so a poisoned lock still holds consistent headers.
34    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    /// ID tokens for `audience`, minted with the identity of the Application Default
45    /// Credentials:
46    /// - a service account key file, as that service account;
47    /// - the metadata server, as the service account of the instance;
48    /// - `impersonated_service_account` credentials, such as
49    ///   `gcloud auth application-default login --impersonate-service-account` writes, as
50    ///   the service account they impersonate;
51    /// - workload identity federation with a `service_account_impersonation_url`, as the
52    ///   service account that URL names, with the federated token calling
53    ///   `generateIdToken` the way it calls `generateAccessToken` for access tokens.
54    ///
55    /// Fails with [`ErrorKind::IdTokenNeedsImpersonation`] for credentials that hold no
56    /// service account: `authorized_user` and `external_account_authorized_user`
57    /// credentials, workload identity federation without impersonation, and
58    /// `gdch_service_account` credentials. Use
59    /// [`id_token_impersonating`](Self::id_token_impersonating) for the first three.
60    ///
61    /// Fails with [`ErrorKind::CryptoProviderMissing`] for a service account key, used
62    /// directly or as the source of an impersonation, when the `auth-default-crypto`
63    /// feature is off and no rustls crypto provider is installed.
64    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    /// ID tokens for `audience`, minted with the identity of `service_account` through the
100    /// IAM Credentials `generateIdToken` method, called with the access tokens of
101    /// `source_credentials`. Their principal needs the `iam.serviceAccounts.getOpenIdToken`
102    /// permission on `service_account`, which the Service Account OpenID Connect Identity
103    /// Token Creator role grants.
104    ///
105    /// `source_credentials` built from a service account key sign with it, and need a
106    /// rustls crypto provider when the `auth-default-crypto` feature is off. They are
107    /// opaque here, so this constructor cannot check for one.
108    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    /// Access tokens for the `cloud-platform` scope, minted with the Application Default
132    /// Credentials: [`from_adc_with_scopes`](Self::from_adc_with_scopes) with
133    /// [`GCP_DEFAULT_SCOPES`].
134    pub async fn from_adc() -> crate::error::Result<Self> {
135        Self::from_adc_with_scopes(GCP_DEFAULT_SCOPES.clone()).await
136    }
137
138    /// Access tokens for `scopes`, minted with the Application Default Credentials.
139    ///
140    /// Fails with [`ErrorKind::CryptoProviderMissing`] for a service account key, used
141    /// directly or as the source of an impersonation, when the `auth-default-crypto`
142    /// feature is off and no rustls crypto provider is installed. Credentials built with
143    /// google-cloud-auth's `Builder` and converted with `GoogleAuthHeaders::from` are not
144    /// checked, and a missing provider panics in their refresh task instead.
145    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    /// The headers to set on a request, replacing any it carries under the same names.
154    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
195/// Access tokens, or whatever headers `credentials` serve.
196///
197/// `credentials` built from a service account key need a rustls crypto provider when
198/// the `auth-default-crypto` feature is off. They are opaque here, so this conversion
199/// cannot check for one; [`GoogleAuthHeaders::from_adc_with_scopes`] checks for
200/// Application Default Credentials.
201impl From<Credentials> for GoogleAuthHeaders {
202    fn from(credentials: Credentials) -> Self {
203        Self {
204            credentials,
205            cached: RwLock::new(None),
206        }
207    }
208}
209
210/// ID tokens from any google-cloud-auth ID token credentials, such as
211/// `idtoken::service_account::Builder` builds from a key that is not the Application
212/// Default Credentials.
213///
214/// ID token credentials built from a service account key need a rustls crypto provider
215/// when the `auth-default-crypto` feature is off. They are opaque here, so this
216/// conversion cannot check for one; [`GoogleAuthHeaders::id_token_from_adc`] checks for
217/// Application Default Credentials.
218impl From<IDTokenCredentials> for GoogleAuthHeaders {
219    fn from(credentials: IDTokenCredentials) -> Self {
220        Self::from(Credentials::from(IdTokenHeaders::from(credentials)))
221    }
222}
223
224/// A workload identity federation configuration that impersonates a service account,
225/// split into that account and the federation without the impersonation.
226///
227/// ID tokens are minted with the federated token itself as the source of the
228/// impersonation: an ID token minted with the impersonated account's own access token
229/// would need that account to hold `getOpenIdToken` on itself.
230#[derive(Debug)]
231struct ImpersonatedFederation {
232    service_account: ServiceAccountEmail,
233    /// The configuration without its `service_account_impersonation_url`.
234    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/// Serves the ID tokens of `IDTokenCredentials` as an `authorization` header, under an
263/// entity tag that changes only when the token does, so that a [`GoogleAuthHeaders`]
264/// caches them like access token headers.
265#[derive(Debug)]
266struct IdTokenHeaders {
267    credentials: IDTokenCredentials,
268    /// Replaced whole under the lock, so a poisoned lock still holds a consistent header.
269    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    /// The tagged header of `id_token`: the current one when the token is unchanged,
289    /// else a new one under a new tag.
290    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    /// ID token credentials that serve the token last scripted. Clones share the script.
348    #[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    /// Installs the rustls provider the README tells applications to install when the
519    /// `auth-default-crypto` feature is off.
520    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    /// The only test in this binary that sets `GOOGLE_APPLICATION_CREDENTIALS`; the others
555    /// pass their Application Default Credentials explicitly.
556    #[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}