From 77545e009176aec437c8cab28816df23088a0bd8 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 8 Oct 2026 00:31:56 +0800 Subject: [PATCH] fix(google): default external account credential format to text --- services/google/src/credential.rs | 45 ++++++++- .../google/src/provide_credential/default.rs | 91 +++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/services/google/src/credential.rs b/services/google/src/credential.rs index 24d7e58f..a355acbc 100644 --- a/services/google/src/credential.rs +++ b/services/google/src/credential.rs @@ -128,7 +128,8 @@ pub mod external_account { pub struct UrlSource { /// The URL to fetch credentials from. pub url: String, - /// The format of the response. + /// The format of the response. Defaults to plain text when omitted. + #[serde(default)] pub format: Format, /// Optional headers to include in the request. pub headers: Option>, @@ -140,7 +141,8 @@ pub mod external_account { pub struct FileSource { /// The file path to read credentials from. pub file: String, - /// The format of the file. + /// The format of the file. Defaults to plain text when omitted. + #[serde(default)] pub format: Format, } @@ -181,7 +183,7 @@ pub mod external_account { } /// Format for parsing credentials. - #[derive(Clone, Deserialize, Debug)] + #[derive(Clone, Default, Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Format { /// JSON format. @@ -190,6 +192,7 @@ pub mod external_account { subject_token_field_name: String, }, /// Plain text format. + #[default] Text, } @@ -435,6 +438,42 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_external_account_source_explicit_format() { + for source in [ + serde_json::json!({"file": "/path/to/token"}), + serde_json::json!({"url": "http://localhost/token"}), + ] { + for (format, data) in [ + (serde_json::json!({"type": "text"}), "test-token"), + ( + serde_json::json!({"type": "json", "subject_token_field_name": "id_token"}), + r#"{"id_token":"test-token"}"#, + ), + ] { + let mut source = source.clone(); + source["format"] = format; + let source: external_account::Source = serde_json::from_value(source).unwrap(); + let format = match source { + external_account::Source::File(source) => source.format, + external_account::Source::Url(source) => source.format, + _ => panic!("expected file or URL source"), + }; + assert_eq!(format.parse(data.as_bytes()).unwrap(), "test-token"); + } + + for invalid_format in [ + serde_json::Value::Null, + serde_json::json!({"type": "unsupported"}), + serde_json::json!({"type": "json"}), + ] { + let mut source = source.clone(); + source["format"] = invalid_format; + assert!(serde_json::from_value::(source).is_err()); + } + } + } + #[test] fn test_parse_service_account_impersonation_url() { let url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/signer%40example.com:generateAccessToken"; diff --git a/services/google/src/provide_credential/default.rs b/services/google/src/provide_credential/default.rs index 3a9e05a7..937e567b 100644 --- a/services/google/src/provide_credential/default.rs +++ b/services/google/src/provide_credential/default.rs @@ -319,6 +319,97 @@ mod tests { assert_eq!("test-234@test.iam.gserviceaccount.com", &sa.client_email); } + #[tokio::test] + async fn test_default_provider_external_account_without_format() -> Result<()> { + #[derive(Debug, Clone, Default)] + struct ExternalAccountHttpSend { + uris: Arc>>, + } + + impl HttpSend for ExternalAccountHttpSend { + async fn http_send(&self, req: http::Request) -> Result> { + self.uris.lock().unwrap().push(req.uri().to_string()); + let body = match req.uri().to_string().as_str() { + "http://localhost/subject-token" => { + assert_eq!(req.method(), http::Method::GET); + Bytes::from_static(b" test-subject-token\n") + } + "https://sts.googleapis.com/v1/token" => { + assert_eq!(req.method(), http::Method::POST); + let params: HashMap<_, _> = + form_urlencoded::parse(req.body()).into_owned().collect(); + assert_eq!(params.get("subject_token").unwrap(), "test-subject-token"); + Bytes::from_static( + br#"{"access_token":"federated-token","token_type":"Bearer","expires_in":3600}"#, + ) + } + uri => panic!("unexpected request or credential fallback: {uri}"), + }; + Ok(http::Response::builder().status(200).body(body).unwrap()) + } + } + + for source in [ + serde_json::json!({"file": "/var/run/oidc-token"}), + serde_json::json!({"url": "http://localhost/subject-token"}), + ] { + let config = serde_json::json!({ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/p/providers/q", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": source, + }); + let file_read = MockFileRead::new(HashMap::from([ + ( + "/adc.json".to_string(), + serde_json::to_vec(&config).unwrap(), + ), + ( + "/var/run/oidc-token".to_string(), + b" test-subject-token\n".to_vec(), + ), + ])); + let http = ExternalAccountHttpSend::default(); + let ctx = Context::new() + .with_file_read(file_read.clone()) + .with_http_send(http.clone()) + .with_env(StaticEnv { + home_dir: None, + envs: HashMap::from([( + GOOGLE_APPLICATION_CREDENTIALS.to_string(), + "/adc.json".to_string(), + )]), + }); + + let credential = DefaultCredentialProvider::new() + .provide_credential(&ctx) + .await? + .expect("external account credential must exist"); + assert_eq!(credential.token.unwrap().access_token, "federated-token"); + if source.get("file").is_some() { + assert_eq!( + *file_read.paths.lock().unwrap(), + ["/adc.json", "/var/run/oidc-token"] + ); + assert_eq!( + *http.uris.lock().unwrap(), + ["https://sts.googleapis.com/v1/token"] + ); + } else { + assert_eq!(*file_read.paths.lock().unwrap(), ["/adc.json"]); + assert_eq!( + *http.uris.lock().unwrap(), + [ + "http://localhost/subject-token", + "https://sts.googleapis.com/v1/token" + ] + ); + } + } + Ok(()) + } + #[tokio::test] async fn test_default_provider_builder_default_chain() { let provider = DefaultCredentialProvider::builder().build();