From d072bb0bed63b352dff8a38b16b02ce8504b6517 Mon Sep 17 00:00:00 2001 From: Naz Quadri Date: Thu, 6 Nov 2025 12:06:33 -0500 Subject: [PATCH] feat(gcp): add support for external_account_authorized_user credentials Adds support for the external_account_authorized_user credential type used by Google Cloud Workforce Identity Federation. This credential format is created when users authenticate with `gcloud auth application-default login` using workforce identity pools with external identity providers. Previously, object_store only supported service_account and authorized_user credential types, causing deserialization failures when ADC files contained external_account_authorized_user credentials. Changes: - Added ExternalAccountAuthorizedUser variant to ApplicationDefaultCredentials enum - Implemented ExternalAccountAuthorizedUserCredentials struct with OAuth2 fields - Implemented TokenProvider trait using custom STS token endpoint - Added conversion to AuthorizedUserCredentials for signing operations - Updated builder.rs to handle new credential type in selection logic The implementation uses the STS (Security Token Service) OAuth token endpoint specified in the credential file for token refresh, following the same pattern as standard authorized_user credentials but with configurable token_url. Tests added: - Unit tests for JSON deserialization (full and minimal formats) - Unit test for credential type conversion - Builder test with sample credentials - Integration test for end-to-end API operations - Manual test with real ADC credentials (#[ignore]) This enables users in enterprise environments with Workforce Identity Federation to use object_store without workarounds, matching the behavior of official Google Cloud client libraries. --- src/gcp/builder.rs | 98 ++++++++++++++++++++++ src/gcp/credential.rs | 189 ++++++++++++++++++++++++++++++++++++++++++ src/gcp/mod.rs | 27 ++++++ 3 files changed, 314 insertions(+) diff --git a/src/gcp/builder.rs b/src/gcp/builder.rs index 581e7011..c0806172 100644 --- a/src/gcp/builder.rs +++ b/src/gcp/builder.rs @@ -533,6 +533,15 @@ impl GoogleCloudStorageBuilder { self.retry_config.clone(), )) as _ } + ApplicationDefaultCredentials::ExternalAccountAuthorizedUser(token) => Arc::new( + TokenCredentialProvider::new( + token, + http.connect(&self.client_options)?, + self.retry_config.clone(), + ) + .with_min_ttl(TOKEN_MIN_TTL), + ) + as _, } } else { Arc::new( @@ -566,6 +575,15 @@ impl GoogleCloudStorageBuilder { ApplicationDefaultCredentials::ServiceAccount(token) => { token.signing_credentials()? } + ApplicationDefaultCredentials::ExternalAccountAuthorizedUser(token) => { + // External account authorized user credentials don't have private keys, + // so we use the same approach as AuthorizedUser + Arc::new(TokenCredentialProvider::new( + AuthorizedUserSigningCredentials::from(token.into())?, + http.connect(&self.client_options)?, + self.retry_config.clone(), + )) as _ + } } } else { Arc::new(TokenCredentialProvider::new( @@ -746,4 +764,84 @@ mod tests { panic!("{key} not propagated as ClientConfigKey"); } } + + #[test] + fn gcs_test_external_account_authorized_user_credentials() { + // Create an external_account_authorized_user credential file + // This format is used by workforce identity federation + let mut creds_file = NamedTempFile::new().unwrap(); + creds_file + .write_all( + br#"{ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/test-pool/providers/test-provider", + "client_id": "test-client-id.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + "token_url": "https://sts.googleapis.com/v1/oauthtoken", + "token_info_url": "https://sts.googleapis.com/v1/introspect", + "quota_project_id": "test-project" +}"#, + ) + .unwrap(); + + // Should successfully deserialize and create a builder + let result = GoogleCloudStorageBuilder::new() + .with_application_credentials(creds_file.path().to_str().unwrap()) + .with_bucket_name("test-bucket") + .build(); + + // Build should succeed - the credentials are valid format + assert!( + result.is_ok(), + "Build should succeed with external_account_authorized_user credentials: {:?}", + result.err() + ); + } + + #[test] + #[ignore] // Only run manually when testing with real ADC + fn gcs_test_real_external_account_authorized_user_adc() { + // This test uses real ADC credentials from the standard location + // Run with: cargo test --features gcp gcs_test_real_external -- --ignored --nocapture + + let home = std::env::var("HOME").expect("HOME not set"); + let adc_path = format!( + "{}/.config/gcloud/application_default_credentials.json", + home + ); + + if !std::path::Path::new(&adc_path).exists() { + println!("⚠️ No ADC file found at {}", adc_path); + return; + } + + // Read and display credential type + let content = std::fs::read_to_string(&adc_path).expect("Failed to read ADC"); + let json: serde_json::Value = serde_json::from_str(&content).expect("Invalid JSON"); + let cred_type = json + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + println!("📋 Testing with ADC credential type: {}", cred_type); + + let result = GoogleCloudStorageBuilder::new() + .with_bucket_name("test-bucket") + .build(); + + match &result { + Ok(_) => println!( + "✅ Successfully built GoogleCloudStorage with {} credentials!", + cred_type + ), + Err(e) => println!("❌ Build failed: {}", e), + } + + assert!( + result.is_ok(), + "Should successfully build with {} credentials from ADC", + cred_type + ); + } } diff --git a/src/gcp/credential.rs b/src/gcp/credential.rs index 2245829f..6b9e2ba2 100644 --- a/src/gcp/credential.rs +++ b/src/gcp/credential.rs @@ -559,6 +559,14 @@ pub(crate) enum ApplicationDefaultCredentials { /// - #[serde(rename = "authorized_user")] AuthorizedUser(AuthorizedUserCredentials), + /// External Account Authorized User via Workforce Identity Federation. + /// + /// Created by `gcloud auth application-default login` when using workforce pools. + /// + /// # References + /// - + #[serde(rename = "external_account_authorized_user")] + ExternalAccountAuthorizedUser(ExternalAccountAuthorizedUserCredentials), } impl ApplicationDefaultCredentials { @@ -599,6 +607,44 @@ pub(crate) struct AuthorizedUserCredentials { refresh_token: String, } +impl From for AuthorizedUserCredentials { + fn from(creds: ExternalAccountAuthorizedUserCredentials) -> Self { + Self { + client_id: creds.client_id, + client_secret: creds.client_secret, + refresh_token: creds.refresh_token, + } + } +} + +/// External Account Authorized User credentials for Workforce Identity Federation. +/// +/// These credentials are created when authenticating through workforce identity pools +/// using `gcloud auth application-default login`. +/// +/// # References +/// - +#[derive(Debug, Deserialize, Clone)] +pub(crate) struct ExternalAccountAuthorizedUserCredentials { + /// OAuth 2.0 client ID + client_id: String, + /// OAuth 2.0 client secret + client_secret: String, + /// Refresh token for obtaining new access tokens + refresh_token: String, + /// STS token endpoint URL + token_url: String, + /// Audience field identifying the workforce pool + #[serde(default)] + audience: Option, + /// Optional quota project ID + #[serde(default)] + quota_project_id: Option, + /// Optional token info URL for introspection + #[serde(default)] + token_info_url: Option, +} + #[derive(Debug, Deserialize)] pub(crate) struct AuthorizedUserSigningCredentials { credential: AuthorizedUserCredentials, @@ -739,6 +785,65 @@ impl TokenProvider for AuthorizedUserCredentials { } } +/// Fetch an access token using a custom token endpoint URL. +/// +/// Used for external account authorized user credentials which specify their own +/// token_url (typically the STS OAuth token endpoint). +async fn get_external_account_token_response( + token_url: &str, + client_id: &str, + client_secret: &str, + refresh_token: &str, + client: &HttpClient, + retry: &RetryConfig, +) -> Result { + client + .post(token_url) + .form([ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("client_secret", client_secret), + ("refresh_token", refresh_token), + ]) + .retryable(retry) + .idempotent(true) + .send() + .await + .map_err(|source| Error::TokenRequest { source })? + .into_body() + .json::() + .await + .map_err(|source| Error::TokenResponseBody { source }) +} + +#[async_trait] +impl TokenProvider for ExternalAccountAuthorizedUserCredentials { + type Credential = GcpCredential; + + async fn fetch_token( + &self, + client: &HttpClient, + retry: &RetryConfig, + ) -> crate::Result>> { + let response = get_external_account_token_response( + &self.token_url, + &self.client_id, + &self.client_secret, + &self.refresh_token, + client, + retry, + ) + .await?; + + Ok(TemporaryToken { + token: Arc::new(GcpCredential { + bearer: response.access_token, + }), + expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)), + }) + } +} + /// Trim whitespace from header values fn trim_header_value(value: &str) -> String { let mut ret = value.to_string(); @@ -961,4 +1066,88 @@ x-goog-meta-reviewer:jane,john" "max-keys=2&prefix=object".to_string() ); } + + #[test] + fn test_deserialize_external_account_authorized_user() { + // Test that we can deserialize external_account_authorized_user credentials + let json = r#"{ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/test-pool/providers/test-provider", + "client_id": "test-client-id.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + "token_url": "https://sts.googleapis.com/v1/oauthtoken", + "token_info_url": "https://sts.googleapis.com/v1/introspect", + "quota_project_id": "test-project" + }"#; + + let creds: ApplicationDefaultCredentials = serde_json::from_str(json).unwrap(); + + match creds { + ApplicationDefaultCredentials::ExternalAccountAuthorizedUser(ref user_creds) => { + assert_eq!( + user_creds.client_id, + "test-client-id.apps.googleusercontent.com" + ); + assert_eq!(user_creds.client_secret, "test-client-secret"); + assert_eq!(user_creds.refresh_token, "test-refresh-token"); + assert_eq!( + user_creds.token_url, + "https://sts.googleapis.com/v1/oauthtoken" + ); + } + _ => panic!("Expected ExternalAccountAuthorizedUser variant"), + } + } + + #[test] + fn test_deserialize_external_account_authorized_user_minimal() { + // Test with minimal required fields only + let json = r#"{ + "type": "external_account_authorized_user", + "client_id": "test-client-id.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + "token_url": "https://sts.googleapis.com/v1/oauthtoken" + }"#; + + let creds: ApplicationDefaultCredentials = serde_json::from_str(json).unwrap(); + + match creds { + ApplicationDefaultCredentials::ExternalAccountAuthorizedUser(ref user_creds) => { + assert_eq!( + user_creds.client_id, + "test-client-id.apps.googleusercontent.com" + ); + assert_eq!( + user_creds.token_url, + "https://sts.googleapis.com/v1/oauthtoken" + ); + assert_eq!(user_creds.audience, None); + assert_eq!(user_creds.quota_project_id, None); + assert_eq!(user_creds.token_info_url, None); + } + _ => panic!("Expected ExternalAccountAuthorizedUser variant"), + } + } + + #[test] + fn test_external_account_authorized_user_conversion() { + // Test conversion to AuthorizedUserCredentials for signing + let external_creds = ExternalAccountAuthorizedUserCredentials { + client_id: "test-client".to_string(), + client_secret: "test-secret".to_string(), + refresh_token: "test-token".to_string(), + token_url: "https://sts.googleapis.com/v1/oauthtoken".to_string(), + audience: Some("//iam.googleapis.com/test".to_string()), + quota_project_id: Some("test-project".to_string()), + token_info_url: Some("https://sts.googleapis.com/v1/introspect".to_string()), + }; + + let auth_user_creds: AuthorizedUserCredentials = external_creds.into(); + + assert_eq!(auth_user_creds.client_id, "test-client"); + assert_eq!(auth_user_creds.client_secret, "test-secret"); + assert_eq!(auth_user_creds.refresh_token, "test-token"); + } } diff --git a/src/gcp/mod.rs b/src/gcp/mod.rs index f8a2e0fc..25424af3 100644 --- a/src/gcp/mod.rs +++ b/src/gcp/mod.rs @@ -450,4 +450,31 @@ mod test { err ) } + + #[tokio::test] + async fn gcs_test_external_account_authorized_user_integration() { + maybe_skip_integration!(); + + // This test verifies that external_account_authorized_user credentials + // (used by Workforce Identity Federation) work end-to-end + let integration = GoogleCloudStorageBuilder::from_env().build().unwrap(); + + // Perform a simple operation to verify credentials work + let path = Path::from("test_external_account_auth_user"); + let data = PutPayload::from("test data for external account authorized user"); + + // Put an object + integration.put(&path, data.clone()).await.unwrap(); + + // Get it back + let result = integration.get(&path).await.unwrap(); + let bytes = result.bytes().await.unwrap(); + assert_eq!( + bytes.as_ref(), + b"test data for external account authorized user" + ); + + // Clean up + integration.delete(&path).await.unwrap(); + } }