diff --git a/Cargo.lock b/Cargo.lock index 6e6fc04646..dc3bf36183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,10 +97,15 @@ name = "auth-integration-tests" version = "0.0.0" dependencies = [ "google-cloud-auth", + "google-cloud-bigquery-v2", "google-cloud-gax", + "google-cloud-iam-credentials-v1", "google-cloud-language-v2", "google-cloud-secretmanager-v1", + "http", + "httptest", "scoped-env", + "serde_json", "serial_test", "tempfile", "tokio", diff --git a/src/auth/integration-tests/Cargo.toml b/src/auth/integration-tests/Cargo.toml index 8e78628246..29691019d6 100644 --- a/src/auth/integration-tests/Cargo.toml +++ b/src/auth/integration-tests/Cargo.toml @@ -20,16 +20,22 @@ edition.workspace = true publish = false [features] -run-integration-tests = [] +run-integration-tests = [] +run-byoid-integration-tests = [] [dependencies] scoped-env.workspace = true tempfile.workspace = true +http.workspace = true +serde_json.workspace = true +httptest.workspace = true # Local dependencies -auth = { path = "../../../src/auth", package = "google-cloud-auth" } -gax = { path = "../../../src/gax", package = "google-cloud-gax" } -language = { path = "../../../src/generated/cloud/language/v2", package = "google-cloud-language-v2" } -secretmanager = { path = "../../../src/generated/cloud/secretmanager/v1", package = "google-cloud-secretmanager-v1" } +auth = { path = "../../../src/auth", package = "google-cloud-auth" } +gax = { path = "../../../src/gax", package = "google-cloud-gax" } +language = { path = "../../../src/generated/cloud/language/v2", package = "google-cloud-language-v2" } +secretmanager = { path = "../../../src/generated/cloud/secretmanager/v1", package = "google-cloud-secretmanager-v1" } +iamcredentials = { path = "../../../src/generated/iam/credentials/v1", package = "google-cloud-iam-credentials-v1" } +bigquery = { path = "../../../src/generated/cloud/bigquery/v2", package = "google-cloud-bigquery-v2" } [dev-dependencies] serial_test = { workspace = true } diff --git a/src/auth/integration-tests/README.md b/src/auth/integration-tests/README.md index ea77f3614e..6a5276c5d8 100644 --- a/src/auth/integration-tests/README.md +++ b/src/auth/integration-tests/README.md @@ -11,6 +11,21 @@ env GOOGLE_CLOUD_PROJECT=rust-auth-testing \ cargo test --features run-integration-tests -p auth-integration-tests ``` +### Workload Identity integration tests + +Those integration tests requires more complex set up to run, like running from +an Azure/AWS VM and having Workload Identity Pools set up. For now we are only +run those tests locally and under a feature (`run-byoid-integration-tests`). +Some extra environment variables with the workload identity pool configuration +are required to run the tests. + +```sh +env GOOGLE_CLOUD_PROJECT=cloud-sdk-auth-test-project \ +env GOOGLE_WORKLOAD_IDENTITY_SERVICE_ACCOUNT= \ +env GOOGLE_WORKLOAD_IDENTITY_OIDC_AUDIENCE=//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/ \ + cargo test run_workload_ --features run-integration-tests --features run-byoid-integration-tests -p auth-integration-tests +``` + #### Rotating the service account key Service account keys expire after 90 days, due to our org policy. diff --git a/src/auth/integration-tests/src/lib.rs b/src/auth/integration-tests/src/lib.rs index ab8b4251d8..b3155abf3a 100644 --- a/src/auth/integration-tests/src/lib.rs +++ b/src/auth/integration-tests/src/lib.rs @@ -15,12 +15,17 @@ use auth::credentials::{ Builder as AccessTokenCredentialBuilder, api_key_credentials::Builder as ApiKeyCredentialsBuilder, + external_account::Builder as ExternalAccountCredentialsBuilder, }; +use bigquery::client::DatasetService; use gax::error::Error; +use httptest::{Expectation, Server, matchers::*, responders::*}; +use iamcredentials::client::IAMCredentials; use language::client::LanguageService; use language::model::Document; use scoped_env::ScopedEnv; use secretmanager::client::SecretManagerService; +use std::collections::HashMap; pub type Result = std::result::Result; @@ -120,3 +125,118 @@ pub async fn api_key() -> Result<()> { Ok(()) } + +pub async fn workload_identity_provider_url_sourced() -> Result<()> { + let project = std::env::var("GOOGLE_CLOUD_PROJECT").expect("GOOGLE_CLOUD_PROJECT not set"); + let audience = get_oidc_audience(); + let service_account = get_byoid_service_account(); + let service_account_data = + serde_json::from_value::>(service_account.clone()) + .expect("failed to read service account data as map"); + let client_email = service_account_data + .get("client_email") + .expect("missing client_email"); + + let id_token = + generate_id_token(audience.clone(), client_email.to_string(), service_account).await?; + + let source_token_response_body = serde_json::json!({ + "id_token": id_token, + }) + .to_string(); + + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("GET", "/source_token"), + request::headers(contains(("metadata", "True",))), + ]) + .respond_with(status_code(200).body(source_token_response_body)), + ); + + let contents = serde_json::json!({ + "type": "external_account", + "audience": audience, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "url": server.url("/source_token").to_string(), + "headers": { + "Metadata": "True" + }, + "format": { + "type": "json", + "subject_token_field_name": "id_token" + } + } + }) + .to_string(); + + // Create external account with Url sourced creds + let creds = + ExternalAccountCredentialsBuilder::new(serde_json::from_str(contents.as_str()).unwrap()) + .build() + .unwrap(); + + // Construct a BigQuery client using the credentials. + // Using BigQuery as it is enabled by default. + let client = DatasetService::builder() + .with_credentials(creds) + .build() + .await?; + + // Make a request using the external account credentials + client + .list_datasets() + .set_project_id(project) + .send() + .await?; + + Ok(()) +} + +/// Generates a Google ID token using the iamcredentials generateIdToken API. +/// https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-oidc +async fn generate_id_token( + audience: String, + client_email: String, + service_account: serde_json::Value, +) -> Result { + let creds = AccessTokenCredentialBuilder::new(service_account.clone()) + .with_scopes(["https://www.googleapis.com/auth/cloud-platform"]) + .build() + .expect("failed to setup service account credentials for IAM"); + + let client = IAMCredentials::builder() + .with_credentials(creds) + .build() + .await + .expect("failed to setup IAM client"); + + let res = client + .generate_id_token() + .set_audience(audience) + .set_include_email(true) + .set_name(format!("projects/-/serviceAccounts/{client_email}")) + .send() + .await?; + + Ok(res.token) +} + +fn get_oidc_audience() -> String { + std::env::var("GOOGLE_WORKLOAD_IDENTITY_OIDC_AUDIENCE") + .expect("GOOGLE_WORKLOAD_IDENTITY_OIDC_AUDIENCE not set") +} + +fn get_byoid_service_account() -> serde_json::Value { + let path = std::env::var("GOOGLE_WORKLOAD_IDENTITY_CREDENTIALS") + .expect("GOOGLE_WORKLOAD_IDENTITY_CREDENTIALS not set"); + + let service_account_content = + std::fs::read_to_string(path).expect("unable to read service account"); + let service_account: serde_json::Value = serde_json::from_str(service_account_content.as_str()) + .expect("unable to parse service account"); + + service_account +} diff --git a/src/auth/integration-tests/tests/driver.rs b/src/auth/integration-tests/tests/driver.rs index 45c3f204b8..54d20a6e4c 100644 --- a/src/auth/integration-tests/tests/driver.rs +++ b/src/auth/integration-tests/tests/driver.rs @@ -26,4 +26,10 @@ mod driver { async fn run_api_key() -> Result<()> { auth_integration_tests::api_key().await } + + #[cfg(all(test, feature = "run-byoid-integration-tests"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn run_workload_identity_provider_url_sourced() -> Result<()> { + auth_integration_tests::workload_identity_provider_url_sourced().await + } } diff --git a/src/auth/src/credentials.rs b/src/auth/src/credentials.rs index b2d291abef..08a608ef94 100644 --- a/src/auth/src/credentials.rs +++ b/src/auth/src/credentials.rs @@ -17,6 +17,9 @@ pub mod mds; pub mod service_account; pub mod user_account; +pub mod external_account; +pub(crate) mod external_account_sources; + pub(crate) mod internal; use crate::Result; @@ -534,6 +537,12 @@ fn build_credentials( |b: service_account::Builder, s: Vec| b .with_access_specifier(service_account::AccessSpecifier::from_scopes(s)) ), + "external_account" => config_builder!( + external_account::Builder::new(json), + quota_project_id, + scopes, + |b: external_account::Builder, s: Vec| b.with_scopes(s) + ), _ => Err(errors::non_retryable_from_str(format!( "Invalid or unsupported credentials type found in JSON: {cred_type}" ))), diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs new file mode 100644 index 0000000000..f9487129e6 --- /dev/null +++ b/src/auth/src/credentials/external_account.rs @@ -0,0 +1,412 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::credentials::Result; +use crate::credentials::internal::sts_exchange::ClientAuthentication; +use crate::errors; +use crate::headers_util::build_cacheable_headers; +use crate::token::{CachedTokenProvider, Token, TokenProvider}; +use crate::token_cache::TokenCache; + +use gax::error::CredentialsError; +use http::{Extensions, HeaderMap}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::time::{Duration, Instant}; + +use super::dynamic::CredentialsProvider; +use super::external_account_sources::url_sourced_account::UrlSourcedSubjectTokenProvider; +use super::internal::sts_exchange::{ExchangeTokenRequest, STSHandler}; +use super::{CacheableResource, Credentials}; + +const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform"; + +#[async_trait::async_trait] +pub(crate) trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { + /// Generate subject token that will be used on STS exchange. + async fn subject_token(&self) -> Result; +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub(crate) struct CredentialSourceFormat { + #[serde(rename = "type")] + pub format_type: String, + pub subject_token_field_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub(crate) struct CredentialSourceHeaders { + #[serde(flatten)] + pub headers: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub(crate) struct ExecutableConfig { + pub command: String, + pub timeout_millis: Option, + pub output_file: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +enum CredentialSource { + UrlSourced { + url: String, + headers: Option, + format: Option, + }, + File { + file: String, + format: Option, + }, + Executable { + executable: ExecutableConfig, + }, + Aws { + environment_id: String, + region_url: Option, + regional_cred_verification_url: Option, + cred_verification_url: Option, + imdsv2_session_token_url: Option, + }, +} + +#[async_trait::async_trait] +impl SubjectTokenProvider for CredentialSource { + async fn subject_token(&self) -> Result { + match self.clone() { + CredentialSource::UrlSourced { + url, + headers, + format, + } => { + let source = UrlSourcedSubjectTokenProvider { + url, + headers, + format, + }; + source.subject_token().await + } + CredentialSource::Executable { .. } => Err(CredentialsError::from_str( + false, + "executable sourced credential not supported yet", + )), + CredentialSource::File { .. } => Err(CredentialsError::from_str( + false, + "file sourced credential not supported yet", + )), + CredentialSource::Aws { .. } => Err(CredentialsError::from_str( + false, + "AWS sourced credential not supported yet", + )), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct ExternalAccountConfig { + audience: String, + subject_token_type: String, + token_url: String, + client_id: Option, + client_secret: Option, + // TODO(#2261): set up impersonation token provider when this attribute is used. + service_account_impersonation_url: Option, + scopes: Option>, + credential_source: CredentialSource, +} + +#[derive(Debug)] +struct ExternalAccountTokenProvider +where + T: SubjectTokenProvider, +{ + subject_token_provider: T, + config: ExternalAccountConfig, +} + +#[async_trait::async_trait] +impl TokenProvider for ExternalAccountTokenProvider +where + T: SubjectTokenProvider, +{ + async fn token(&self) -> Result { + let subject_token = self.subject_token_provider.subject_token().await?; + + let audience = self.config.audience.clone(); + let subject_token_type = self.config.subject_token_type.clone(); + let url = self.config.token_url.clone(); + let mut scope = vec![]; + if let Some(scopes) = self.config.scopes.clone() { + scopes.into_iter().for_each(|v| scope.push(v)); + } + if scope.is_empty() { + scope.push(CLOUD_PLATFORM_SCOPE.to_string()); + } + let req = ExchangeTokenRequest { + url, + audience: Some(audience), + subject_token, + subject_token_type, + scope, + authentication: ClientAuthentication { + client_id: self.config.client_id.clone(), + client_secret: self.config.client_secret.clone(), + }, + ..ExchangeTokenRequest::default() + }; + + let token_res = STSHandler::exchange_token(req).await?; + + let token = Token { + token: token_res.access_token, + token_type: token_res.token_type, + expires_at: Some(Instant::now() + Duration::from_secs(token_res.expires_in)), + metadata: None, + }; + Ok(token) + } +} + +#[derive(Debug)] +pub(crate) struct ExternalAccountCredentials +where + T: CachedTokenProvider, +{ + token_provider: T, + quota_project_id: Option, +} + +/// A builder for constructing external account [Credentials] instances. +/// +/// # Example +/// ``` +/// # use google_cloud_auth::credentials::external_account::{Builder}; +/// # tokio_test::block_on(async { +/// let project_id = project_id(); +/// let workload_identity_pool_id = workload_identity_pool(); +/// let provider_id = workload_identity_provider(); +/// let provider_name = format!( +/// "//iam.googleapis.com/projects/{project_id}/locations/global/workloadIdentityPools/{workload_identity_pool_id}/providers/{provider_id}" +/// ); +/// let config = serde_json::json!({ +/// "type": "external_account", +/// "audience": provider_name, +/// "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", +/// "token_url": "https://sts.googleapis.com/v1beta/token", +/// "credential_source": { +/// "url": format!("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={provider_name}"), +/// "headers": { +/// "Metadata": "True" +/// }, +/// "format": { +/// "type": "json", +/// "subject_token_field_name": "access_token" +/// } +/// } +/// }); +/// let credentials = Builder::new(config) +/// .with_quota_project_id("quota_project") +/// .build(); +/// }); +/// +/// fn project_id() -> String { +/// "test-only".to_string() +/// } +/// fn workload_identity_pool() -> String { +/// "test-only".to_string() +/// } +/// fn workload_identity_provider() -> String { +/// "test-only".to_string() +/// } +/// ``` +pub struct Builder { + external_account_config: Value, + quota_project_id: Option, + scopes: Option>, +} + +impl Builder { + /// Creates a new builder using [external_account_config] JSON value. + /// + /// [external_account_config]: https://cloud.google.com/iam/docs/workload-download-cred-and-grant-access#download-configuration + pub fn new(external_account_config: Value) -> Self { + Self { + external_account_config, + quota_project_id: None, + scopes: None, + } + } + + /// Sets the [quota project] for this credentials. + /// + /// In some services, you can use a service account in + /// one project for authentication and authorization, and charge + /// the usage to a different project. This requires that the + /// service account has `serviceusage.services.use` permissions on the quota project. + /// + /// [quota project]: https://cloud.google.com/docs/quotas/quota-project + pub fn with_quota_project_id>(mut self, quota_project_id: S) -> Self { + self.quota_project_id = Some(quota_project_id.into()); + self + } + + /// Overrides the [scopes] for this credentials. + /// + /// [scopes]: https://developers.google.com/identity/protocols/oauth2/scopes + pub fn with_scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = Some(scopes.into_iter().map(|s| s.into()).collect()); + self + } + + /// Returns a [Credentials] instance with the configured settings. + /// + /// # Errors + /// + /// Returns a [CredentialsError] if the `external_account_config` + /// provided to [`Builder::new`] cannot be successfully deserialized into the + /// expected format for an external account configuration. This typically happens if the + /// JSON value is malformed or missing required fields. For more information, + /// on the expected format, consult the relevant section in + /// the [external account config] guide. + /// + /// [external account config]: https://cloud.google.com/iam/docs/workload-download-cred-and-grant-access#download-configuration + pub fn build(self) -> Result { + let external_account_config: ExternalAccountConfig = + serde_json::from_value(self.external_account_config).map_err(errors::non_retryable)?; + + let mut config = external_account_config.clone(); + if let Some(scopes) = self.scopes { + config.scopes = Some(scopes); + } + + let token_provider = ExternalAccountTokenProvider { + subject_token_provider: external_account_config.credential_source, + config, + }; + + Ok(Credentials { + inner: Arc::new(ExternalAccountCredentials { + quota_project_id: self.quota_project_id.clone(), + token_provider: TokenCache::new(token_provider), + }), + }) + } +} + +#[async_trait::async_trait] +impl CredentialsProvider for ExternalAccountCredentials +where + T: CachedTokenProvider, +{ + async fn headers(&self, extensions: Extensions) -> Result> { + let token = self.token_provider.token(extensions).await?; + build_cacheable_headers(&token, &self.quota_project_id) + } +} + +#[cfg(test)] +mod test { + use super::*; + use serde_json::*; + + #[tokio::test] + async fn create_external_account_builder() { + let contents = json!({ + "type": "external_account", + "audience": "//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1beta/token", + "credential_source": { + "url": "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/", + "headers": { + "Metadata": "True" + }, + "format": { + "type": "json", + "subject_token_field_name": "access_token" + } + } + }); + + let creds = Builder::new(contents) + .with_quota_project_id("test_project") + .with_scopes(["a", "b"]) + .build() + .unwrap(); + + let fmt = format!("{:?}", creds); + print!("{:?}", creds); + assert!(fmt.contains("ExternalAccountCredentials")); + } + + #[tokio::test] + async fn create_external_account_detect_url_sourced() { + let contents = json!({ + "type": "external_account", + "audience": "//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1beta/token", + "credential_source": { + "url": "http://169.254.169.254/metadata/identity/oauth2/token", + "headers": { + "Metadata": "True" + }, + "format": { + "type": "json", + "subject_token_field_name": "access_token" + } + } + }); + + let config: ExternalAccountConfig = + serde_json::from_value(contents).expect("failed to parse external account config"); + let source = config.credential_source; + + match source { + CredentialSource::UrlSourced { + url, + headers, + format, + } => { + assert_eq!( + url, + "http://169.254.169.254/metadata/identity/oauth2/token".to_string() + ); + assert_eq!( + headers, + Some(CredentialSourceHeaders { + headers: HashMap::from([("Metadata".to_string(), "True".to_string()),]), + }) + ); + assert_eq!( + format, + Some(CredentialSourceFormat { + format_type: "json".to_string(), + subject_token_field_name: "access_token".to_string(), + }) + ) + } + _ => { + unreachable!("expected Url Sourced credential") + } + } + } +} diff --git a/src/auth/src/credentials/external_account_sources.rs b/src/auth/src/credentials/external_account_sources.rs new file mode 100644 index 0000000000..506bff9176 --- /dev/null +++ b/src/auth/src/credentials/external_account_sources.rs @@ -0,0 +1,15 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub mod url_sourced_account; diff --git a/src/auth/src/credentials/external_account_sources/url_sourced_account.rs b/src/auth/src/credentials/external_account_sources/url_sourced_account.rs new file mode 100644 index 0000000000..6620caad45 --- /dev/null +++ b/src/auth/src/credentials/external_account_sources/url_sourced_account.rs @@ -0,0 +1,90 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use gax::error::CredentialsError; +use reqwest::Client; +use serde_json::Value; +use std::time::Duration; + +use crate::{ + Result, + credentials::external_account::{ + CredentialSourceFormat, CredentialSourceHeaders, SubjectTokenProvider, + }, +}; + +#[derive(Debug)] +pub(crate) struct UrlSourcedSubjectTokenProvider { + pub url: String, + pub headers: Option, + pub format: Option, +} + +#[async_trait::async_trait] +impl SubjectTokenProvider for UrlSourcedSubjectTokenProvider { + async fn subject_token(&self) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + + let mut request = client.get(self.url.clone()); + + if let Some(headers) = &self.headers { + for (key, value) in &headers.headers { + request = request.header(key.as_str(), value.as_str()); + } + } + + let response = request.send().await.map_err(|err| { + CredentialsError::from_str(false, format!("failed to request subject token: {}", err)) + })?; + + if !response.status().is_success() { + return Err(CredentialsError::from_str( + false, + "failed to request subject token", + )); + } + + let response_text = response.text().await.map_err(|err| { + CredentialsError::from_str( + false, + format!("failed to read subject token response: {}", err), + ) + })?; + + match &self.format { + Some(format) => { + let json_response: Value = serde_json::from_str(&response_text).unwrap(); + let subject_token = json_response + .get(&format.subject_token_field_name) + .and_then(Value::as_str) + .map(String::from); + + match subject_token { + Some(token) => Ok(token), + None => Err(CredentialsError::from_str( + false, + format!( + "failed to read subject token field `{}` from response: {}", + format.subject_token_field_name, json_response + ), + )), + } + } + None => Ok(response_text), + } + } +} diff --git a/src/auth/src/credentials/internal/sts_exchange.rs b/src/auth/src/credentials/internal/sts_exchange.rs index 80c60a3836..c9fc43c6b2 100644 --- a/src/auth/src/credentials/internal/sts_exchange.rs +++ b/src/auth/src/credentials/internal/sts_exchange.rs @@ -112,7 +112,7 @@ pub struct TokenResponse { pub issued_token_type: String, pub token_type: String, pub expires_in: u64, - pub scope: String, + pub scope: Option, pub refresh_token: Option, } @@ -257,7 +257,7 @@ mod test { issued_token_type: ACCESS_TOKEN_TYPE.to_string(), token_type: "Bearer".to_string(), expires_in: 3600, - scope: "https://www.googleapis.com/auth/cloud-platform".to_string(), + scope: Some("https://www.googleapis.com/auth/cloud-platform".to_string()), } ); diff --git a/src/auth/tests/credentials.rs b/src/auth/tests/credentials.rs index 7393491fd4..390d3895af 100644 --- a/src/auth/tests/credentials.rs +++ b/src/auth/tests/credentials.rs @@ -29,11 +29,14 @@ type Result = std::result::Result; mod test { use super::*; use google_cloud_auth::credentials::EntityTag; - use http::header::{HeaderName, HeaderValue}; + use http::header::{AUTHORIZATION, HeaderName, HeaderValue}; use http::{Extensions, HeaderMap}; + use httptest::{Expectation, Server, matchers::*, responders::*}; use scoped_env::ScopedEnv; use std::error::Error; + type TestResult = std::result::Result<(), Box>; + #[tokio::test] #[serial_test::serial] async fn create_access_token_credentials_fallback_to_mds() { @@ -193,6 +196,95 @@ mod test { assert!(!fmt.contains("test-api-key"), "{fmt:?}"); } + #[tokio::test] + async fn create_external_account_access_token() -> TestResult { + let source_token_response_body = json!({ + "access_token":"an_example_token", + }) + .to_string(); + + let token_response_body = json!({ + "access_token":"an_exchanged_token", + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3600, + "scope":"https://www.googleapis.com/auth/cloud-platform" + }) + .to_string(); + + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("GET", "/source_token"), + request::headers(contains(("metadata", "True",))), + ]) + .respond_with(status_code(200).body(source_token_response_body)), + ); + + server.expect( + Expectation::matching(all_of![ + request::method_path("POST", "/token"), + request::body(url_decoded(contains(("subject_token", "an_example_token")))), + request::body(url_decoded(contains(( + "subject_token_type", + "urn:ietf:params:oauth:token-type:jwt" + )))), + request::body(url_decoded(contains(( + "audience", + "//iam.googleapis.com/projects/654269145772/locations/global/workloadIdentityPools/byoid-pool/providers/azure-pid" + )))), + request::headers(contains(( + "content-type", + "application/x-www-form-urlencoded" + ))), + ]) + .respond_with(status_code(200).body(token_response_body)), + ); + + let contents = json!({ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/654269145772/locations/global/workloadIdentityPools/byoid-pool/providers/azure-pid", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": server.url("/token").to_string(), + "credential_source": { + "url": server.url("/source_token").to_string(), + "headers": { + "Metadata": "True" + }, + "format": { + "type": "json", + "subject_token_field_name": "access_token" + } + } + }).to_string(); + + let creds = + AccessTokenCredentialBuilder::new(serde_json::from_str(contents.as_str()).unwrap()) + .build() + .unwrap(); + + let fmt = format!("{:?}", creds); + print!("{:?}", creds); + assert!(fmt.contains("ExternalAccountCredentials")); + + let cached_headers = creds.headers(Extensions::new()).await?; + match cached_headers { + CacheableResource::New { data, .. } => { + let token = data + .get(AUTHORIZATION) + .and_then(|token_value| token_value.to_str().ok()) + .map(|s| s.to_string()) + .unwrap(); + assert!(token.contains("Bearer an_exchanged_token")); + } + CacheableResource::NotModified => { + unreachable!("Expecting a header to be present"); + } + }; + + Ok(()) + } + mockall::mock! { #[derive(Debug)] Credentials {}