1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
11use std::fmt;
12use std::hash::BuildHasher;
13use std::io::Read;
14use std::sync::Arc;
15
16use google_cloud_auth::credentials::AccessTokenCredentials;
17use object_store::client::CredentialProvider;
18use object_store::gcp::{
19 GcpCredential, GcpCredentialProvider, GoogleCloudStorageBuilder, GoogleConfigKey,
20};
21use parking_lot::Mutex;
22use serde_json::Value;
23
24const MAX_APPLICATION_CREDENTIAL_BYTES: usize = 1024 * 1024;
25const GCS_READ_WRITE_SCOPE: &str = "https://www.googleapis.com/auth/devstorage.read_write";
26const APPLICATION_CREDENTIAL_KEYS: &[&str] =
27 &["google_application_credentials", "application_credentials"];
28const EXPLICIT_SERVICE_ACCOUNT_KEYS: &[&str] = &[
29 "google_service_account",
30 "service_account",
31 "google_service_account_path",
32 "service_account_path",
33 "google_service_account_key",
34 "service_account_key",
35];
36const AMBIENT_SERVICE_ACCOUNT_KEYS: &[&str] = &[
37 "SERVICE_ACCOUNT",
38 "GOOGLE_SERVICE_ACCOUNT",
39 "GOOGLE_SERVICE_ACCOUNT_PATH",
40 "GOOGLE_SERVICE_ACCOUNT_KEY",
41];
42
43#[derive(Debug, thiserror::Error)]
45pub enum GcsCredentialError {
46 #[error("the selected GCS application credentials file could not be read")]
48 UnreadableApplicationCredentials,
49 #[error("the selected GCS application credentials file exceeds the 1 MiB limit")]
51 ApplicationCredentialsTooLarge,
52 #[error("the selected GCS application credentials file is not valid JSON")]
54 InvalidApplicationCredentialsJson,
55 #[error("the selected GCS external-account credentials have an invalid '{0}' field")]
57 InvalidExternalAccountField(&'static str),
58 #[error("the selected GCS application credential type is unsupported")]
60 UnsupportedApplicationCredentialType,
61 #[error("the selected GCS external-account credentials are invalid")]
63 InvalidExternalAccountConfiguration,
64 #[error("the GCS workload-identity provider could not acquire an access token")]
66 TokenAcquisition,
67 #[error("the GCS workload-identity provider returned an empty access token")]
69 EmptyAccessToken,
70}
71
72pub fn gcs_workload_identity_provider<F, S>(
84 explicit: &HashMap<String, String, S>,
85 env_lookup: &F,
86) -> Result<Option<GcpCredentialProvider>, GcsCredentialError>
87where
88 F: Fn(&str) -> Option<String>,
89 S: BuildHasher,
90{
91 if has_non_empty_option(explicit, EXPLICIT_SERVICE_ACCOUNT_KEYS) {
92 return Ok(None);
93 }
94 if let Some(path) = non_empty_option(explicit, APPLICATION_CREDENTIAL_KEYS) {
95 return load_workload_identity_provider(path);
96 }
97 if has_non_empty_environment(env_lookup, AMBIENT_SERVICE_ACCOUNT_KEYS) {
98 return Ok(None);
99 }
100 let Some(path) =
101 env_lookup("GOOGLE_APPLICATION_CREDENTIALS").filter(|value| !value.trim().is_empty())
102 else {
103 return Ok(None);
104 };
105 load_workload_identity_provider(&path)
106}
107
108pub fn configure_gcs_workload_identity<I, K, V>(
115 builder: GoogleCloudStorageBuilder,
116 options: I,
117 credentials: GcpCredentialProvider,
118) -> GoogleCloudStorageBuilder
119where
120 I: IntoIterator<Item = (K, V)>,
121 K: AsRef<str>,
122 V: Into<String>,
123{
124 options
125 .into_iter()
126 .fold(builder, |builder, (key, value)| {
127 let Ok(key) = key.as_ref().to_ascii_lowercase().parse::<GoogleConfigKey>() else {
128 return builder;
129 };
130 if is_credential_key(key) {
131 builder
132 } else {
133 builder.with_config(key, value)
134 }
135 })
136 .with_credentials(credentials)
137}
138
139fn is_credential_key(key: GoogleConfigKey) -> bool {
140 matches!(
141 key,
142 GoogleConfigKey::ServiceAccount
143 | GoogleConfigKey::ServiceAccountKey
144 | GoogleConfigKey::ApplicationCredentials
145 )
146}
147
148fn non_empty_option<'a, S>(
149 options: &'a HashMap<String, String, S>,
150 keys: &[&str],
151) -> Option<&'a str>
152where
153 S: BuildHasher,
154{
155 keys.iter().find_map(|key| {
156 options.iter().find_map(|(candidate, value)| {
157 (candidate.eq_ignore_ascii_case(key) && !value.trim().is_empty())
158 .then_some(value.as_str())
159 })
160 })
161}
162
163fn has_non_empty_option<S>(options: &HashMap<String, String, S>, keys: &[&str]) -> bool
164where
165 S: BuildHasher,
166{
167 non_empty_option(options, keys).is_some()
168}
169
170fn has_non_empty_environment<F>(env_lookup: &F, keys: &[&str]) -> bool
171where
172 F: Fn(&str) -> Option<String>,
173{
174 keys.iter()
175 .any(|key| env_lookup(key).is_some_and(|value| !value.trim().is_empty()))
176}
177
178fn load_workload_identity_provider(
179 path: &str,
180) -> Result<Option<GcpCredentialProvider>, GcsCredentialError> {
181 let file = std::fs::File::open(path)
182 .map_err(|_| GcsCredentialError::UnreadableApplicationCredentials)?;
183 let mut bytes = Vec::new();
184 file.take((MAX_APPLICATION_CREDENTIAL_BYTES + 1) as u64)
185 .read_to_end(&mut bytes)
186 .map_err(|_| GcsCredentialError::UnreadableApplicationCredentials)?;
187 if bytes.len() > MAX_APPLICATION_CREDENTIAL_BYTES {
188 return Err(GcsCredentialError::ApplicationCredentialsTooLarge);
189 }
190 let document: Value = serde_json::from_slice(&bytes)
191 .map_err(|_| GcsCredentialError::InvalidApplicationCredentialsJson)?;
192 let credential_type = required_string(&document, "type")?;
193 match credential_type {
194 "service_account" | "authorized_user" => Ok(None),
195 "external_account" => {
196 validate_external_account_document(&document)?;
197 Ok(Some(Arc::new(WorkloadIdentityCredentialProvider {
198 document: Mutex::new(Some(document)),
199 credentials: tokio::sync::OnceCell::new(),
200 })))
201 }
202 _ => Err(GcsCredentialError::UnsupportedApplicationCredentialType),
203 }
204}
205
206fn validate_external_account_document(document: &Value) -> Result<(), GcsCredentialError> {
207 for field in ["audience", "subject_token_type", "token_url"] {
208 required_string(document, field)?;
209 }
210 if !document
211 .get("credential_source")
212 .is_some_and(Value::is_object)
213 {
214 return Err(GcsCredentialError::InvalidExternalAccountField(
215 "credential_source",
216 ));
217 }
218 Ok(())
219}
220
221fn required_string<'a>(
222 document: &'a Value,
223 field: &'static str,
224) -> Result<&'a str, GcsCredentialError> {
225 document
226 .get(field)
227 .and_then(Value::as_str)
228 .filter(|value| !value.trim().is_empty())
229 .ok_or(GcsCredentialError::InvalidExternalAccountField(field))
230}
231
232struct WorkloadIdentityCredentialProvider {
233 document: Mutex<Option<Value>>,
234 credentials: tokio::sync::OnceCell<AccessTokenCredentials>,
235}
236
237impl fmt::Debug for WorkloadIdentityCredentialProvider {
238 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239 formatter.write_str("GcsWorkloadIdentityCredentialProvider")
240 }
241}
242
243#[async_trait::async_trait]
244impl CredentialProvider for WorkloadIdentityCredentialProvider {
245 type Credential = GcpCredential;
246
247 async fn get_credential(&self) -> object_store::Result<Arc<GcpCredential>> {
248 let credentials = self
249 .credentials
250 .get_or_try_init(|| async {
251 let document = self.document.lock().take().ok_or_else(|| {
252 object_store_error(GcsCredentialError::InvalidExternalAccountConfiguration)
253 })?;
254 google_cloud_auth::credentials::external_account::Builder::new(document)
255 .with_scopes([GCS_READ_WRITE_SCOPE])
256 .build_access_token_credentials()
257 .map_err(|_| {
258 object_store_error(GcsCredentialError::InvalidExternalAccountConfiguration)
259 })
260 })
261 .await?;
262 let token = credentials
263 .access_token()
264 .await
265 .map_err(|_| object_store_error(GcsCredentialError::TokenAcquisition))?;
266 if token.token.trim().is_empty() {
267 return Err(object_store_error(GcsCredentialError::EmptyAccessToken));
268 }
269 Ok(Arc::new(GcpCredential {
270 bearer: token.token,
271 }))
272 }
273}
274
275fn object_store_error(error: GcsCredentialError) -> object_store::Error {
276 object_store::Error::Generic {
277 store: "GCS authentication",
278 source: Box::new(error),
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn external_account_document(subject_token_file: &str) -> Value {
287 serde_json::json!({
288 "type": "external_account",
289 "audience": "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/test/providers/github",
290 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
291 "token_url": "https://sts.googleapis.com/v1/token",
292 "credential_source": { "file": subject_token_file }
293 })
294 }
295
296 fn write_credentials(directory: &tempfile::TempDir, name: &str, document: &Value) -> String {
297 let path = directory.path().join(name);
298 std::fs::write(&path, serde_json::to_vec(document).unwrap()).unwrap();
299 path.to_string_lossy().into_owned()
300 }
301
302 #[test]
303 fn explicit_external_account_precedes_ambient_service_account() {
304 let directory = tempfile::tempdir().unwrap();
305 let path = write_credentials(
306 &directory,
307 "external.json",
308 &external_account_document("subject-token"),
309 );
310 let options = HashMap::from([("google_application_credentials".into(), path)]);
311 let provider = gcs_workload_identity_provider(&options, &|name| {
312 (name == "GOOGLE_SERVICE_ACCOUNT_KEY").then(|| "not-selected".into())
313 })
314 .unwrap()
315 .unwrap();
316 assert_eq!(
317 format!("{provider:?}"),
318 "GcsWorkloadIdentityCredentialProvider"
319 );
320 }
321
322 #[test]
323 fn explicit_service_account_retains_downstream_provider() {
324 let options =
325 HashMap::from([("google_service_account_key".into(), "not-inspected".into())]);
326 let selected = gcs_workload_identity_provider(&options, &|name| {
327 (name == "GOOGLE_APPLICATION_CREDENTIALS").then(|| "ambient.json".into())
328 })
329 .unwrap();
330 assert!(selected.is_none());
331 }
332
333 #[test]
334 fn supported_non_external_adc_retains_downstream_provider() {
335 let directory = tempfile::tempdir().unwrap();
336 let path = write_credentials(
337 &directory,
338 "authorized-user.json",
339 &serde_json::json!({ "type": "authorized_user" }),
340 );
341 let options = HashMap::from([("application_credentials".into(), path)]);
342 assert!(gcs_workload_identity_provider(&options, &|_| None)
343 .unwrap()
344 .is_none());
345 }
346
347 #[test]
348 fn errors_and_debug_output_do_not_disclose_credentials() {
349 let directory = tempfile::tempdir().unwrap();
350 let secret = "do-not-disclose";
351 let path = write_credentials(
352 &directory,
353 secret,
354 &serde_json::json!({
355 "type": "external_account",
356 "token_url": format!("https://{secret}.example/token"),
357 "credential_source": { "file": secret }
358 }),
359 );
360 let options = HashMap::from([("google_application_credentials".into(), path)]);
361 let error = gcs_workload_identity_provider(&options, &|_| None)
362 .unwrap_err()
363 .to_string();
364 assert!(error.contains("audience"), "{error}");
365 assert!(!error.contains(secret), "{error}");
366 }
367
368 #[test]
369 fn credential_file_read_is_bounded() {
370 let directory = tempfile::tempdir().unwrap();
371 let path = directory.path().join("oversized.json");
372 std::fs::write(&path, vec![b'x'; MAX_APPLICATION_CREDENTIAL_BYTES + 1]).unwrap();
373 let options = HashMap::from([(
374 "google_application_credentials".into(),
375 path.to_string_lossy().into_owned(),
376 )]);
377
378 let error = gcs_workload_identity_provider(&options, &|_| None).unwrap_err();
379 assert!(matches!(
380 error,
381 GcsCredentialError::ApplicationCredentialsTooLarge
382 ));
383 }
384
385 #[test]
386 fn credential_options_are_not_applied_to_the_object_store_builder() {
387 let directory = tempfile::tempdir().unwrap();
388 let path = write_credentials(
389 &directory,
390 "external.json",
391 &external_account_document("subject-token"),
392 );
393 let options = HashMap::from([("google_application_credentials".into(), path)]);
394 let provider = gcs_workload_identity_provider(&options, &|_| None)
395 .unwrap()
396 .unwrap();
397 let builder = configure_gcs_workload_identity(
398 GoogleCloudStorageBuilder::new().with_url("gs://test-bucket/prefix"),
399 options,
400 provider,
401 );
402 assert!(builder.build().is_ok());
403 }
404
405 #[tokio::test]
406 async fn external_account_provider_acquires_and_reuses_sdk_token() {
407 use tokio::io::{AsyncReadExt, AsyncWriteExt};
408
409 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
410 let token_url = format!("http://{}/token", listener.local_addr().unwrap());
411 let server = tokio::spawn(async move {
412 let (mut stream, _) = listener.accept().await.unwrap();
413 let mut request = [0_u8; 8 * 1024];
414 let length = stream.read(&mut request).await.unwrap();
415 assert!(String::from_utf8_lossy(&request[..length]).starts_with("POST /token "));
416 let body = r#"{"access_token":"test-access-token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600}"#;
417 let response = format!(
418 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
419 body.len()
420 );
421 stream.write_all(response.as_bytes()).await.unwrap();
422 });
423
424 let directory = tempfile::tempdir().unwrap();
425 let subject_token_path = directory.path().join("subject-token");
426 std::fs::write(&subject_token_path, "test-subject-token").unwrap();
427 let credential_path = write_credentials(
428 &directory,
429 "external.json",
430 &serde_json::json!({
431 "type": "external_account",
432 "audience": "test-audience",
433 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
434 "token_url": token_url,
435 "credential_source": { "file": subject_token_path }
436 }),
437 );
438 let options = HashMap::from([("google_application_credentials".into(), credential_path)]);
439 let provider = gcs_workload_identity_provider(&options, &|_| None)
440 .unwrap()
441 .unwrap();
442
443 let first =
444 tokio::time::timeout(std::time::Duration::from_secs(5), provider.get_credential())
445 .await
446 .unwrap()
447 .unwrap();
448 let second = provider.get_credential().await.unwrap();
449 assert_eq!(first.bearer, "test-access-token");
450 assert_eq!(second.bearer, first.bearer);
451 server.await.unwrap();
452 }
453}