From 96219af56405b62df7c6a83caadc58f1bb8a371b Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Thu, 22 Jan 2026 21:08:39 +0000 Subject: [PATCH 1/5] impl(auth): trust boundaries draft --- src/auth/src/credentials/external_account.rs | 14 +- src/auth/src/credentials/impersonated.rs | 2 +- src/auth/src/credentials/mds.rs | 2 +- src/auth/src/credentials/service_account.rs | 33 ++- src/auth/src/credentials/user_account.rs | 2 +- src/auth/src/headers_util.rs | 72 +++++- src/auth/src/lib.rs | 2 + src/auth/src/trust_boundary.rs | 227 +++++++++++++++++++ 8 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 src/auth/src/trust_boundary.rs diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs index 877de0c47c..cef2a26c27 100644 --- a/src/auth/src/credentials/external_account.rs +++ b/src/auth/src/credentials/external_account.rs @@ -125,6 +125,7 @@ use crate::headers_util::build_cacheable_headers; use crate::retry::Builder as RetryTokenProviderBuilder; use crate::token::{CachedTokenProvider, Token, TokenProvider}; use crate::token_cache::TokenCache; +use crate::trust_boundary::TrustBoundary; use crate::{BuildResult, Result}; use gax::backoff_policy::BackoffPolicyArg; use gax::retry_policy::RetryPolicyArg; @@ -359,16 +360,21 @@ impl ExternalAccountConfig { where T: dynamic::SubjectTokenProvider + 'static, { + let trust_boundary_url = + crate::trust_boundary::external_account_lookup_url(&config.audience); let token_provider = ExternalAccountTokenProvider { subject_token_provider, config, }; let token_provider_with_retry = retry_builder.build(token_provider); let cache = TokenCache::new(token_provider_with_retry); + let trust_boundary = + trust_boundary_url.map(|url| Arc::new(TrustBoundary::new(cache.clone(), url))); AccessTokenCredentials { inner: Arc::new(ExternalAccountCredentials { token_provider: cache, quota_project_id, + trust_boundary, }), } } @@ -457,6 +463,7 @@ where { token_provider: T, quota_project_id: Option, + trust_boundary: Option>, } /// A builder for external account [Credentials] instances. @@ -1279,7 +1286,12 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; - build_cacheable_headers(&token, &self.quota_project_id) + let trust_boundary_header = self + .trust_boundary + .as_ref() + .and_then(|tb| tb.header_value()); + + build_cacheable_headers(&token, &self.quota_project_id, &trust_boundary_header) } } diff --git a/src/auth/src/credentials/impersonated.rs b/src/auth/src/credentials/impersonated.rs index a4af732861..6ac04098f7 100644 --- a/src/auth/src/credentials/impersonated.rs +++ b/src/auth/src/credentials/impersonated.rs @@ -706,7 +706,7 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; - build_cacheable_headers(&token, &self.quota_project_id) + build_cacheable_headers(&token, &self.quota_project_id, &None) } } diff --git a/src/auth/src/credentials/mds.rs b/src/auth/src/credentials/mds.rs index 94979cc348..bd20451c6c 100644 --- a/src/auth/src/credentials/mds.rs +++ b/src/auth/src/credentials/mds.rs @@ -368,7 +368,7 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let cached_token = self.token_provider.token(extensions).await?; - build_cacheable_headers(&cached_token, &self.quota_project_id) + build_cacheable_headers(&cached_token, &self.quota_project_id, &None) } } diff --git a/src/auth/src/credentials/service_account.rs b/src/auth/src/credentials/service_account.rs index 007c2a4a69..afda6abbf0 100644 --- a/src/auth/src/credentials/service_account.rs +++ b/src/auth/src/credentials/service_account.rs @@ -80,6 +80,7 @@ use crate::errors::{self}; use crate::headers_util::build_cacheable_headers; use crate::token::{CachedTokenProvider, Token, TokenProvider}; use crate::token_cache::TokenCache; +use crate::trust_boundary::TrustBoundary; use crate::{BuildResult, Result}; use async_trait::async_trait; use http::{Extensions, HeaderMap}; @@ -328,10 +329,21 @@ impl Builder { /// /// [service account keys]: https://cloud.google.com/iam/docs/keys-create-delete#creating pub fn build_access_token_credentials(self) -> BuildResult { + let quota_project_id = self.quota_project_id.clone(); + let token_provider = self.build_token_provider()?; + let client_email = token_provider.service_account_key.client_email.clone(); + + let token_provider = TokenCache::new(token_provider); + let trust_boundary_url = crate::trust_boundary::service_account_lookup_url(&client_email); + let trust_boundary = Arc::new(TrustBoundary::new( + token_provider.clone(), + trust_boundary_url, + )); Ok(AccessTokenCredentials { inner: Arc::new(ServiceAccountCredentials { - quota_project_id: self.quota_project_id.clone(), - token_provider: TokenCache::new(self.build_token_provider()?), + quota_project_id, + token_provider, + trust_boundary, }), }) } @@ -451,6 +463,7 @@ where { token_provider: T, quota_project_id: Option, + trust_boundary: Arc, } #[derive(Debug)] @@ -562,7 +575,9 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; - build_cacheable_headers(&token, &self.quota_project_id) + let trust_boundary_header = self.trust_boundary.header_value(); + + build_cacheable_headers(&token, &self.quota_project_id, &trust_boundary_header) } } @@ -641,9 +656,11 @@ mod tests { let mut mock = MockTokenProvider::new(); mock.expect_token().times(1).return_once(|| Ok(token)); + let cache = TokenCache::new(mock); let sac = ServiceAccountCredentials { - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), quota_project_id: None, + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let mut extensions = Extensions::new(); @@ -683,9 +700,11 @@ mod tests { let mut mock = MockTokenProvider::new(); mock.expect_token().times(1).return_once(|| Ok(token)); + let cache = TokenCache::new(mock); let sac = ServiceAccountCredentials { - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), quota_project_id: Some(quota_project.to_string()), + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let headers = get_headers_from_cache(sac.headers(Extensions::new()).await.unwrap())?; @@ -710,9 +729,11 @@ mod tests { .times(1) .return_once(|| Err(errors::non_retryable_from_str("fail"))); + let cache = TokenCache::new(mock); let sac = ServiceAccountCredentials { - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), quota_project_id: None, + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; assert!(sac.headers(Extensions::new()).await.is_err()); } diff --git a/src/auth/src/credentials/user_account.rs b/src/auth/src/credentials/user_account.rs index 1e2fdd698e..7ffe04f19d 100644 --- a/src/auth/src/credentials/user_account.rs +++ b/src/auth/src/credentials/user_account.rs @@ -505,7 +505,7 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; - build_cacheable_headers(&token, &self.quota_project_id) + build_cacheable_headers(&token, &self.quota_project_id, &None) } } diff --git a/src/auth/src/headers_util.rs b/src/auth/src/headers_util.rs index 667cc02be1..39e040e413 100644 --- a/src/auth/src/headers_util.rs +++ b/src/auth/src/headers_util.rs @@ -16,6 +16,7 @@ use crate::Result; use crate::credentials::{CacheableResource, QUOTA_PROJECT_KEY}; use crate::errors; use crate::token::Token; +use crate::trust_boundary::TRUST_BOUNDARY_HEADER; use http::HeaderMap; use http::header::{AUTHORIZATION, HeaderName, HeaderValue}; @@ -51,11 +52,12 @@ const API_KEY_HEADER_KEY: &str = "x-goog-api-key"; pub(crate) fn build_cacheable_headers( cached_token: &CacheableResource, quota_project_id: &Option, + trust_boundary_header: &Option, ) -> Result> { match cached_token { CacheableResource::NotModified => Ok(CacheableResource::NotModified), CacheableResource::New { entity_tag, data } => { - let headers = build_bearer_headers(data, quota_project_id)?; + let headers = build_bearer_headers(data, quota_project_id, trust_boundary_header)?; Ok(CacheableResource::New { entity_tag: entity_tag.clone(), data: headers, @@ -68,11 +70,18 @@ pub(crate) fn build_cacheable_headers( fn build_bearer_headers( token: &crate::token::Token, quota_project_id: &Option, + trust_boundary_header: &Option, ) -> Result { - build_headers(token, quota_project_id, AUTHORIZATION, |token| { - HeaderValue::from_str(&format!("{} {}", token.token_type, token.token)) - .map_err(errors::non_retryable) - }) + build_headers( + token, + quota_project_id, + trust_boundary_header, + AUTHORIZATION, + |token| { + HeaderValue::from_str(&format!("{} {}", token.token_type, token.token)) + .map_err(errors::non_retryable) + }, + ) } pub(crate) fn build_cacheable_api_key_headers( @@ -95,6 +104,7 @@ fn build_api_key_headers(token: &crate::token::Token) -> Result { build_headers( token, &None, + &None, HeaderName::from_static(API_KEY_HEADER_KEY), |token| HeaderValue::from_str(&token.token).map_err(errors::non_retryable), ) @@ -104,6 +114,7 @@ fn build_api_key_headers(token: &crate::token::Token) -> Result { fn build_headers( token: &crate::token::Token, quota_project_id: &Option, + trust_boundary_header: &Option, header_name: HeaderName, build_header_value: impl FnOnce(&crate::token::Token) -> Result, ) -> Result { @@ -120,6 +131,13 @@ fn build_headers( ); } + if let Some(trust_boundary) = trust_boundary_header { + header_map.insert( + HeaderName::from_static(TRUST_BOUNDARY_HEADER), + HeaderValue::from_str(trust_boundary).map_err(errors::non_retryable)?, + ); + } + Ok(header_map) } @@ -147,7 +165,7 @@ mod tests { data: token, }; - let result = build_cacheable_headers(&cacheable_token, &None); + let result = build_cacheable_headers(&cacheable_token, &None, &None); assert!(result.is_ok()); let cached_headers = result.unwrap(); @@ -169,7 +187,7 @@ mod tests { fn build_cacheable_headers_basic_not_modified() { let cacheable_token = CacheableResource::NotModified; - let result = build_cacheable_headers(&cacheable_token, &None); + let result = build_cacheable_headers(&cacheable_token, &None, &None); assert!(result.is_ok()); let cached_headers = result.unwrap(); @@ -188,7 +206,7 @@ mod tests { }; let quota_project_id = Some("test-project-123".to_string()); - let result = build_cacheable_headers(&cacheable_token, "a_project_id); + let result = build_cacheable_headers(&cacheable_token, "a_project_id, &None); assert!(result.is_ok()); let cached_headers = result.unwrap(); @@ -210,11 +228,45 @@ mod tests { assert_eq!(quota_project, HeaderValue::from_static("test-project-123")); } + #[test] + fn build_cacheable_headers_with_trust_boundary_success() { + let token = create_test_token("test_token", "Bearer"); + let cacheable_token = CacheableResource::New { + entity_tag: EntityTag::default(), + data: token, + }; + + let trust_boundary = Some("test-trust-boundary".to_string()); + let result = build_cacheable_headers(&cacheable_token, &None, &trust_boundary); + + assert!(result.is_ok()); + let cached_headers = result.unwrap(); + let headers = match cached_headers { + CacheableResource::New { data, .. } => data, + CacheableResource::NotModified => unreachable!("expecting new headers"), + }; + assert_eq!(headers.len(), 2, "{headers:?}"); + + let token = headers + .get(HeaderName::from_static("authorization")) + .unwrap(); + assert_eq!(token, HeaderValue::from_static("Bearer test_token")); + assert!(token.is_sensitive()); + + let trust_boundary = headers + .get(HeaderName::from_static(TRUST_BOUNDARY_HEADER)) + .unwrap(); + assert_eq!( + trust_boundary, + HeaderValue::from_static("test-trust-boundary") + ); + } + #[test] fn build_bearer_headers_different_token_type() { let token = create_test_token("special_token", "MAC"); - let result = build_bearer_headers(&token, &None); + let result = build_bearer_headers(&token, &None, &None); assert!(result.is_ok()); let headers = result.unwrap(); @@ -233,7 +285,7 @@ mod tests { fn build_bearer_headers_invalid_token() { let token = create_test_token("token with \n invalid chars", "Bearer"); - let result = build_bearer_headers(&token, &None); + let result = build_bearer_headers(&token, &None, &None); assert!(result.is_err()); let error = result.unwrap_err(); diff --git a/src/auth/src/lib.rs b/src/auth/src/lib.rs index c6bc028113..826b7f95dd 100644 --- a/src/auth/src/lib.rs +++ b/src/auth/src/lib.rs @@ -74,4 +74,6 @@ pub(crate) mod retry; /// [Credentials]: https://cloud.google.com/docs/authentication#credentials pub(crate) mod headers_util; +pub(crate) mod trust_boundary; + pub mod signer; diff --git a/src/auth/src/trust_boundary.rs b/src/auth/src/trust_boundary.rs new file mode 100644 index 0000000000..c637d75d7b --- /dev/null +++ b/src/auth/src/trust_boundary.rs @@ -0,0 +1,227 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::credentials::CacheableResource; +use crate::errors::CredentialsError; +use crate::headers_util::build_cacheable_headers; +use crate::token::CachedTokenProvider; +use http::Extensions; +use reqwest::Client; +use std::clone::Clone; +use std::fmt::Debug; +use tokio::sync::watch; +use tokio::time::{Duration, sleep}; + +pub(crate) const TRUST_BOUNDARY_HEADER: &str = "x-goog-allowed-locations"; +const TRUST_BOUNDARIES_ENV_VAR: &str = "GOOGLE_AUTH_ENABLE_TRUST_BOUNDARIES"; +const NO_OP_ENCODED_LOCATIONS: &str = "0x0"; + +// Refresh interval: 1 hour +const REFRESH_INTERVAL: Duration = Duration::from_secs(3600); +// Retry interval on error: 1 minute +const ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub(crate) struct TrustBoundary { + rx_header: watch::Receiver>, +} + +impl Clone for TrustBoundary { + fn clone(&self) -> Self { + Self { + rx_header: self.rx_header.clone(), + } + } +} + +impl TrustBoundary { + pub(crate) fn new(token_provider: T, url: String) -> Self + where + T: CachedTokenProvider + Clone + 'static, + { + let enabled = std::env::var(TRUST_BOUNDARIES_ENV_VAR) + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false); + + let (tx_header, rx_header) = watch::channel(None); + + if enabled { + tokio::spawn(refresh_task(token_provider, url, tx_header)); + } + + Self { rx_header } + } + + pub(crate) fn header_value(&self) -> Option { + let val = self.rx_header.borrow().clone(); + if let Some(ref v) = val { + if v == NO_OP_ENCODED_LOCATIONS { + return None; + } + } + val + } +} + +async fn fetch_trust_boundary( + token_provider: &T, + url: &str, + client: &Client, +) -> Result, CredentialsError> +where + T: CachedTokenProvider, +{ + let token = token_provider.token(Extensions::new()).await?; + let headers = build_cacheable_headers(&token, &None, &None)?; + let headers = match headers { + CacheableResource::New { data, .. } => data, + CacheableResource::NotModified => { + unreachable!("requested trust boundary without a caching etag") + } + }; + + let resp = client + .get(url) + .headers(headers) + .send() + .await + .map_err(|e| CredentialsError::from_msg(true, e.to_string()))?; + + // TODO: add error handling - default fallback ? + if !resp.status().is_success() { + return Err(CredentialsError::from_msg( + true, + format!("Failed to fetch trust boundary: {}", resp.status()), + )); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| CredentialsError::from_msg(true, e.to_string()))?; + + if let Some(locations) = json.get("locations").and_then(|l| l.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(",") + }) { + if !locations.is_empty() { + return Ok(Some(locations)); + } + } + + Ok(None) +} + +async fn refresh_task(token_provider: T, url: String, tx_header: watch::Sender>) +where + T: CachedTokenProvider + Clone + 'static, +{ + let client = Client::new(); + loop { + match fetch_trust_boundary(&token_provider, &url, &client).await { + Ok(val) => { + let _ = tx_header.send(val); + sleep(REFRESH_INTERVAL).await; + } + Err(_e) => { + // TODO: add error handling - default fallback ? + sleep(ERROR_RETRY_INTERVAL).await; + } + } + } +} + +pub(crate) fn service_account_lookup_url(email: &str) -> String { + format!( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}/allowedLocations", + email + ) +} + +pub(crate) fn external_account_lookup_url(audience: &str) -> Option { + let path = audience + .trim_start_matches("//iam.googleapis.com/") + .trim_start_matches("https://iam.googleapis.com/") + .trim_start_matches('/'); + + let parts: Vec<&str> = path.split('/').collect(); + + // Workload: projects/{project}/locations/global/workloadIdentityPools/{pool}/providers/{provider} (6 parts) + if parts.len() >= 6 + && parts[0] == "projects" + && parts[2] == "locations" + && parts[4] == "workloadIdentityPools" + { + let project = parts[1]; + let pool = parts[5]; + return Some(format!( + "https://iamcredentials.googleapis.com/v1/projects/{}/locations/global/workloadIdentityPools/{}/allowedLocations", + project, pool + )); + } + + // Workforce: locations/global/workforcePools/{pool}/providers/{provider} (4 parts) + if parts.len() >= 4 && parts[0] == "locations" && parts[2] == "workforcePools" { + let pool = parts[3]; + return Some(format!( + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/{}/allowedLocations", + pool + )); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_service_account_url() { + assert_eq!( + service_account_lookup_url("sa@project.iam.gserviceaccount.com"), + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com/allowedLocations" + ); + } + + #[test] + fn test_external_account_url_workload() { + let aud = "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + assert_eq!( + external_account_lookup_url(aud).unwrap(), + "https://iamcredentials.googleapis.com/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations" + ); + } + + #[test] + fn test_external_account_url_workforce() { + let aud = + "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + assert_eq!( + external_account_lookup_url(aud).unwrap(), + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/my-pool/allowedLocations" + ); + } + + #[test] + fn test_external_account_url_invalid() { + assert!(external_account_lookup_url("invalid").is_none()); + assert!( + external_account_lookup_url("//iam.googleapis.com/projects/123/locations/global/wrong") + .is_none() + ); + } +} From cccf9d0d728d7e9d5ac3fda8be3dd4663b07cf88 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Fri, 23 Jan 2026 15:30:42 +0000 Subject: [PATCH 2/5] fix: don't cache reqwest client --- src/auth/src/trust_boundary.rs | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/auth/src/trust_boundary.rs b/src/auth/src/trust_boundary.rs index c637d75d7b..f810e0cb42 100644 --- a/src/auth/src/trust_boundary.rs +++ b/src/auth/src/trust_boundary.rs @@ -37,23 +37,14 @@ pub(crate) struct TrustBoundary { rx_header: watch::Receiver>, } -impl Clone for TrustBoundary { - fn clone(&self) -> Self { - Self { - rx_header: self.rx_header.clone(), - } - } -} - impl TrustBoundary { pub(crate) fn new(token_provider: T, url: String) -> Self where - T: CachedTokenProvider + Clone + 'static, + T: CachedTokenProvider + 'static, { let enabled = std::env::var(TRUST_BOUNDARIES_ENV_VAR) .map(|v| v.to_lowercase() == "true") .unwrap_or(false); - let (tx_header, rx_header) = watch::channel(None); if enabled { @@ -77,7 +68,6 @@ impl TrustBoundary { async fn fetch_trust_boundary( token_provider: &T, url: &str, - client: &Client, ) -> Result, CredentialsError> where T: CachedTokenProvider, @@ -91,6 +81,9 @@ where } }; + let client = Client::new(); + + // TODO: retries ? let resp = client .get(url) .headers(headers) @@ -127,17 +120,16 @@ where async fn refresh_task(token_provider: T, url: String, tx_header: watch::Sender>) where - T: CachedTokenProvider + Clone + 'static, + T: CachedTokenProvider, { - let client = Client::new(); loop { - match fetch_trust_boundary(&token_provider, &url, &client).await { + match fetch_trust_boundary(&token_provider, &url).await { Ok(val) => { let _ = tx_header.send(val); sleep(REFRESH_INTERVAL).await; } Err(_e) => { - // TODO: add error handling - default fallback ? + // TODO: better error handling - default fallback ? sleep(ERROR_RETRY_INTERVAL).await; } } From 9a52da4638b5b2c0862eb8bd696f4bf4ffea84f4 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Fri, 23 Jan 2026 21:01:01 +0000 Subject: [PATCH 3/5] impl: support mds credential where url resolution is async --- src/auth/src/credentials/mds.rs | 32 +++++++++++-- src/auth/src/trust_boundary.rs | 82 ++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/auth/src/credentials/mds.rs b/src/auth/src/credentials/mds.rs index 0a1dfdab9e..6d9c3b07a4 100644 --- a/src/auth/src/credentials/mds.rs +++ b/src/auth/src/credentials/mds.rs @@ -86,6 +86,7 @@ use crate::mds::{ use crate::retry::{Builder as RetryTokenProviderBuilder, TokenProviderWithRetry}; use crate::token::{CachedTokenProvider, Token, TokenProvider}; use crate::token_cache::TokenCache; +use crate::trust_boundary::TrustBoundary; use crate::{BuildResult, Result}; use async_trait::async_trait; use gax::backoff_policy::BackoffPolicyArg; @@ -114,6 +115,7 @@ where { quota_project_id: Option, token_provider: T, + trust_boundary: Arc, } /// Creates [Credentials] instances backed by the [Metadata Service]. @@ -312,9 +314,20 @@ impl Builder { /// # }); /// ``` pub fn build_access_token_credentials(self) -> BuildResult { + let quota_project_id = self.quota_project_id.clone(); + let (final_endpoint, _) = self.resolve_endpoint(); + let mds_client = MDSClient::new(Some(final_endpoint.clone())); + let token_provider = TokenCache::new(self.build_token_provider()); + + let trust_boundary = Arc::new(TrustBoundary::new_for_mds( + token_provider.clone(), + mds_client.clone(), + )); + let mdsc = MDSCredentials { - quota_project_id: self.quota_project_id.clone(), - token_provider: TokenCache::new(self.build_token_provider()), + quota_project_id, + token_provider, + trust_boundary, }; Ok(AccessTokenCredentials { inner: Arc::new(mdsc), @@ -368,7 +381,12 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let cached_token = self.token_provider.token(extensions).await?; - build_cacheable_headers(&cached_token, &self.quota_project_id, &None) + let trust_boundary_header_value = self.trust_boundary.header_value(); + build_cacheable_headers( + &cached_token, + &self.quota_project_id, + &trust_boundary_header_value, + ) } } @@ -640,9 +658,11 @@ mod tests { let mut mock = MockTokenProvider::new(); mock.expect_token().times(1).return_once(|| Ok(token)); + let cache = TokenCache::new(mock); let mdsc = MDSCredentials { quota_project_id: None, - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let mut extensions = Extensions::new(); @@ -700,9 +720,11 @@ mod tests { .times(1) .return_once(|| Err(errors::non_retryable_from_str("fail"))); + let cache = TokenCache::new(mock); let mdsc = MDSCredentials { quota_project_id: None, - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; assert!(mdsc.headers(Extensions::new()).await.is_err()); } diff --git a/src/auth/src/trust_boundary.rs b/src/auth/src/trust_boundary.rs index f810e0cb42..d3892d5c74 100644 --- a/src/auth/src/trust_boundary.rs +++ b/src/auth/src/trust_boundary.rs @@ -15,6 +15,7 @@ use crate::credentials::CacheableResource; use crate::errors::CredentialsError; use crate::headers_util::build_cacheable_headers; +use crate::mds::client::Client as MDSClient; use crate::token::CachedTokenProvider; use http::Extensions; use reqwest::Client; @@ -42,9 +43,7 @@ impl TrustBoundary { where T: CachedTokenProvider + 'static, { - let enabled = std::env::var(TRUST_BOUNDARIES_ENV_VAR) - .map(|v| v.to_lowercase() == "true") - .unwrap_or(false); + let enabled = Self::is_trust_boundaries_enabled(); let (tx_header, rx_header) = watch::channel(None); if enabled { @@ -54,6 +53,26 @@ impl TrustBoundary { Self { rx_header } } + pub(crate) fn new_for_mds(token_provider: T, mds_client: MDSClient) -> Self + where + T: CachedTokenProvider + 'static, + { + let enabled = Self::is_trust_boundaries_enabled(); + let (tx_header, rx_header) = watch::channel(None); + + if enabled { + tokio::spawn(refresh_task_mds(token_provider, mds_client, tx_header)); + } + + Self { rx_header } + } + + fn is_trust_boundaries_enabled() -> bool { + std::env::var(TRUST_BOUNDARIES_ENV_VAR) + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false) + } + pub(crate) fn header_value(&self) -> Option { let val = self.rx_header.borrow().clone(); if let Some(ref v) = val { @@ -118,20 +137,59 @@ where Ok(None) } +async fn refresh_task_mds( + token_provider: T, + mds_client: MDSClient, + tx_header: watch::Sender>, +) where + T: CachedTokenProvider, +{ + let mut url: Option = None; + + loop { + if url.is_none() { + let res = mds_client.email().await; + match res { + Ok(email) => { + url = Some(service_account_lookup_url(&email)); + } + Err(_e) => { + sleep(ERROR_RETRY_INTERVAL).await; + continue; + } + } + } + + if let Some(ref url) = url { + fetch_and_update(&token_provider, url, &tx_header).await; + } + } +} + async fn refresh_task(token_provider: T, url: String, tx_header: watch::Sender>) where T: CachedTokenProvider, { loop { - match fetch_trust_boundary(&token_provider, &url).await { - Ok(val) => { - let _ = tx_header.send(val); - sleep(REFRESH_INTERVAL).await; - } - Err(_e) => { - // TODO: better error handling - default fallback ? - sleep(ERROR_RETRY_INTERVAL).await; - } + fetch_and_update(&token_provider, &url, &tx_header).await; + } +} + +async fn fetch_and_update( + token_provider: &T, + url: &str, + tx_header: &watch::Sender>, +) where + T: CachedTokenProvider, +{ + match fetch_trust_boundary(token_provider, url).await { + Ok(val) => { + let _ = tx_header.send(val); + sleep(REFRESH_INTERVAL).await; + } + Err(_e) => { + // TODO: better error handling - default fallback ? + sleep(ERROR_RETRY_INTERVAL).await; } } } From 88568e9ab47ab113a561eda85fbb48aab1fe8757 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Fri, 23 Jan 2026 21:03:49 +0000 Subject: [PATCH 4/5] impl: struct for allowed location endpoint --- src/auth/src/trust_boundary.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/auth/src/trust_boundary.rs b/src/auth/src/trust_boundary.rs index d3892d5c74..3587e8a7dc 100644 --- a/src/auth/src/trust_boundary.rs +++ b/src/auth/src/trust_boundary.rs @@ -84,6 +84,14 @@ impl TrustBoundary { } } +#[derive(serde::Deserialize)] +struct AllowedLocationsResponse { + #[allow(dead_code)] + locations: Vec, + #[serde(rename = "encodedLocations")] + encoded_locations: String, +} + async fn fetch_trust_boundary( token_provider: &T, url: &str, @@ -118,20 +126,13 @@ where )); } - let json: serde_json::Value = resp + let response: AllowedLocationsResponse = resp .json() .await .map_err(|e| CredentialsError::from_msg(true, e.to_string()))?; - if let Some(locations) = json.get("locations").and_then(|l| l.as_array()).map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join(",") - }) { - if !locations.is_empty() { - return Ok(Some(locations)); - } + if !response.encoded_locations.is_empty() { + return Ok(Some(response.encoded_locations)); } Ok(None) From e973459c0e2e79c581a91244c5e6a0e87c65e7fe Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Fri, 23 Jan 2026 21:12:55 +0000 Subject: [PATCH 5/5] fix: resolve_endpoint logic changed --- src/auth/src/credentials/mds.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/auth/src/credentials/mds.rs b/src/auth/src/credentials/mds.rs index 3f1f6a7b67..6c1c6cf0ad 100644 --- a/src/auth/src/credentials/mds.rs +++ b/src/auth/src/credentials/mds.rs @@ -285,8 +285,7 @@ impl Builder { /// ``` pub fn build_access_token_credentials(self) -> BuildResult { let quota_project_id = self.quota_project_id.clone(); - let (final_endpoint, _) = self.resolve_endpoint(); - let mds_client = MDSClient::new(Some(final_endpoint.clone())); + let mds_client = MDSClient::new(self.endpoint.clone()); let token_provider = TokenCache::new(self.build_token_provider()); let trust_boundary = Arc::new(TrustBoundary::new_for_mds(