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(); + } }