Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/gcp/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test seems more like an example -- I wonder if it would be better as an example (or a no-run doc test)

// 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
);
}
}
189 changes: 189 additions & 0 deletions src/gcp/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,14 @@ pub(crate) enum ApplicationDefaultCredentials {
/// - <https://google.aip.dev/auth/4113>
#[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
/// - <https://cloud.google.com/iam/docs/workforce-identity-federation>
#[serde(rename = "external_account_authorized_user")]
ExternalAccountAuthorizedUser(ExternalAccountAuthorizedUserCredentials),
}

impl ApplicationDefaultCredentials {
Expand Down Expand Up @@ -599,6 +607,44 @@ pub(crate) struct AuthorizedUserCredentials {
refresh_token: String,
}

impl From<ExternalAccountAuthorizedUserCredentials> 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
/// - <https://cloud.google.com/iam/docs/workforce-identity-federation>
#[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<String>,
/// Optional quota project ID
#[serde(default)]
quota_project_id: Option<String>,
/// Optional token info URL for introspection
#[serde(default)]
token_info_url: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AuthorizedUserSigningCredentials {
credential: AuthorizedUserCredentials,
Expand Down Expand Up @@ -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<TokenResponse> {
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::<TokenResponse>()
.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<TemporaryToken<Arc<GcpCredential>>> {
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();
Expand Down Expand Up @@ -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");
}
}
27 changes: 27 additions & 0 deletions src/gcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand how this test is verifying external_account_authorized_user -- it doesn't seem to configure such 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();
}
}
Loading