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::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/// The authentication headers of google-cloud-auth credentials, for every request the
19/// tonic and reqwest middleware send: `authorization` with an access token or an ID token,
20/// and whatever else the credentials add, such as `x-goog-user-project` for a quota
21/// project.
22///
23/// The credentials mint, cache and refresh the tokens. A `GoogleAuthHeaders` keeps the
24/// headers of the current token, with `authorization` marked sensitive so that it stays
25/// out of logs, and takes new ones only when the credentials report a new token.
26///
27/// [`from_adc`](Self::from_adc), [`from_adc_with_scopes`](Self::from_adc_with_scopes),
28/// [`from_service_account_key`](Self::from_service_account_key),
29/// [`id_token_from_adc`](Self::id_token_from_adc) and
30/// [`id_token_impersonating`](Self::id_token_impersonating) build credentials, which
31/// spawns their refresh task on the current Tokio runtime. All but the last check for the
32/// rustls crypto provider a service account key signs with, and fail with
33/// [`ErrorKind::CryptoProviderMissing`] instead of panicking in that task.
34#[derive(Debug)]
35pub struct GoogleAuthHeaders {
36    credentials: Credentials,
37    /// Replaced whole under the lock, so a poisoned lock still holds consistent headers.
38    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    /// ID tokens for `audience`, minted with the identity of the Application Default
49    /// Credentials:
50    /// - a service account key file, as that service account;
51    /// - the metadata server, as the service account of the instance;
52    /// - `impersonated_service_account` credentials, such as
53    ///   `gcloud auth application-default login --impersonate-service-account` writes, as
54    ///   the service account they impersonate;
55    /// - workload identity federation with a `service_account_impersonation_url`, as the
56    ///   service account that URL names, with the federated token calling
57    ///   `generateIdToken` the way it calls `generateAccessToken` for access tokens.
58    ///
59    /// Fails with [`ErrorKind::IdTokenNeedsImpersonation`] for credentials that hold no
60    /// service account: `authorized_user` and `external_account_authorized_user`
61    /// credentials, workload identity federation without impersonation, and
62    /// `gdch_service_account` credentials. Use
63    /// [`id_token_impersonating`](Self::id_token_impersonating) for the first three.
64    ///
65    /// Fails with [`ErrorKind::CryptoProviderMissing`] for a service account key, used
66    /// directly or as the source of an impersonation, when the `auth-default-crypto`
67    /// feature is off and no rustls crypto provider is installed.
68    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    /// ID tokens for `audience`, minted with the identity of `service_account` through the
104    /// IAM Credentials `generateIdToken` method, called with the access tokens of
105    /// `source_credentials`. Their principal needs the `iam.serviceAccounts.getOpenIdToken`
106    /// permission on `service_account`, which the Service Account OpenID Connect Identity
107    /// Token Creator role grants.
108    ///
109    /// `source_credentials` built from a service account key sign with it, and need a
110    /// rustls crypto provider when the `auth-default-crypto` feature is off. They are
111    /// opaque here, so this constructor cannot check for one.
112    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    /// Access tokens for the `cloud-platform` scope, minted with the Application Default
136    /// Credentials: [`from_adc_with_scopes`](Self::from_adc_with_scopes) with
137    /// [`GCP_DEFAULT_SCOPES`].
138    pub async fn from_adc() -> crate::error::Result<Self> {
139        Self::from_adc_with_scopes(GCP_DEFAULT_SCOPES.clone()).await
140    }
141
142    /// Access tokens for `scopes`, minted with the Application Default Credentials.
143    ///
144    /// Fails with [`ErrorKind::CryptoProviderMissing`] for a service account key, used
145    /// directly or as the source of an impersonation, when the `auth-default-crypto`
146    /// feature is off and no rustls crypto provider is installed. Credentials built with
147    /// google-cloud-auth's `Builder` and converted with `GoogleAuthHeaders::from` are not
148    /// checked, and a missing provider panics in their refresh task instead.
149    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    /// Access tokens for `scopes`, minted with the service account key `key`, the JSON of
158    /// a key file as the IAM console or `gcloud iam service-accounts keys create` writes it.
159    ///
160    /// Fails with [`ErrorKind::CryptoProviderMissing`] when the `auth-default-crypto`
161    /// feature is off and no rustls crypto provider is installed, and with
162    /// [`ErrorKind::CredentialsBuild`] when `key` lacks the fields of a service account key.
163    /// The private key itself is parsed on the first token request.
164    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    /// The headers to set on a request, replacing any it carries under the same names.
176    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
217/// Access tokens, or whatever headers `credentials` serve.
218///
219/// `credentials` built from a service account key need a rustls crypto provider when
220/// the `auth-default-crypto` feature is off. They are opaque here, so this conversion
221/// cannot check for one; [`GoogleAuthHeaders::from_adc_with_scopes`] checks for
222/// Application Default Credentials and [`GoogleAuthHeaders::from_service_account_key`] for
223/// a key.
224impl From<Credentials> for GoogleAuthHeaders {
225    fn from(credentials: Credentials) -> Self {
226        Self {
227            credentials,
228            cached: RwLock::new(None),
229        }
230    }
231}
232
233/// ID tokens from any google-cloud-auth ID token credentials, such as
234/// `idtoken::service_account::Builder` builds from a key that is not the Application
235/// Default Credentials.
236///
237/// ID token credentials built from a service account key need a rustls crypto provider
238/// when the `auth-default-crypto` feature is off. They are opaque here, so this
239/// conversion cannot check for one; [`GoogleAuthHeaders::id_token_from_adc`] checks for
240/// Application Default Credentials.
241impl From<IDTokenCredentials> for GoogleAuthHeaders {
242    fn from(credentials: IDTokenCredentials) -> Self {
243        Self::from(Credentials::from(IdTokenHeaders::from(credentials)))
244    }
245}
246
247/// A workload identity federation configuration that impersonates a service account,
248/// split into that account and the federation without the impersonation.
249///
250/// ID tokens are minted with the federated token itself as the source of the
251/// impersonation: an ID token minted with the impersonated account's own access token
252/// would need that account to hold `getOpenIdToken` on itself.
253#[derive(Debug)]
254struct ImpersonatedFederation {
255    service_account: ServiceAccountEmail,
256    /// The configuration without its `service_account_impersonation_url`.
257    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/// Serves the ID tokens of `IDTokenCredentials` as an `authorization` header, under an
286/// entity tag that changes only when the token does, so that a [`GoogleAuthHeaders`]
287/// caches them like access token headers.
288#[derive(Debug)]
289struct IdTokenHeaders {
290    credentials: IDTokenCredentials,
291    /// Replaced whole under the lock, so a poisoned lock still holds a consistent header.
292    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    /// The tagged header of `id_token`: the current one when the token is unchanged,
312    /// else a new one under a new tag.
313    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    /// ID token credentials that serve the token last scripted. Clones share the script.
371    #[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    /// Installs the rustls provider the README tells applications to install when the
542    /// `auth-default-crypto` feature is off.
543    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    /// The only test in this binary that sets `GOOGLE_APPLICATION_CREDENTIALS`; the others
591    /// pass their Application Default Credentials explicitly.
592    #[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}