From c7814f6ce1f6803dc10111a0a7dc02a793ad8310 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Wed, 15 Apr 2026 11:38:01 -0400 Subject: [PATCH 1/3] impl(oauth2): add method populate AllowedLocationsRequest type to Credentials --- google/cloud/BUILD.bazel | 4 + .../internal/oauth2_api_key_credentials.cc | 9 +-- .../internal/oauth2_api_key_credentials.h | 5 +- .../internal/oauth2_cached_credentials.cc | 5 ++ .../internal/oauth2_cached_credentials.h | 2 + .../oauth2_compute_engine_credentials.cc | 10 +++ .../oauth2_compute_engine_credentials.h | 2 + .../oauth2_compute_engine_credentials_test.cc | 14 ++++ google/cloud/internal/oauth2_credentials.cc | 5 +- google/cloud/internal/oauth2_credentials.h | 33 +++++++- .../oauth2_external_account_credentials.cc | 46 ++++++++++- .../oauth2_external_account_credentials.h | 10 +++ ...auth2_external_account_credentials_test.cc | 77 ++++++++++++++----- ...impersonate_service_account_credentials.cc | 16 +++- ..._impersonate_service_account_credentials.h | 5 +- ...sonate_service_account_credentials_test.cc | 15 ++++ .../internal/oauth2_logging_credentials.cc | 6 ++ .../internal/oauth2_logging_credentials.h | 2 + .../oauth2_minimal_iam_credentials_rest.h | 13 ---- .../oauth2_service_account_credentials.cc | 10 +++ .../oauth2_service_account_credentials.h | 2 + ...oauth2_service_account_credentials_test.cc | 14 ++++ 22 files changed, 255 insertions(+), 50 deletions(-) diff --git a/google/cloud/BUILD.bazel b/google/cloud/BUILD.bazel index dd4feee33dcf0..708169ddda3d5 100644 --- a/google/cloud/BUILD.bazel +++ b/google/cloud/BUILD.bazel @@ -237,6 +237,8 @@ cc_library( name = "google_cloud_cpp_rest_internal", srcs = google_cloud_cpp_rest_internal_srcs, hdrs = google_cloud_cpp_rest_internal_hdrs, + # TODO(#16079): Remove macro definition when GA. + cxxopts = ["-DGOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB"], linkopts = select({ "@platforms//os:windows": [ "-DEFAULTLIB:bcrypt.lib", @@ -273,6 +275,8 @@ cc_library( [cc_test( name = test.replace("/", "_").replace(".cc", ""), srcs = [test], + # TODO(#16079): Remove macro definition when GA. + cxxopts = ["-DGOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB"], deps = [ ":google_cloud_cpp_rest_internal", "//google/cloud/testing_util:google_cloud_cpp_testing_private", diff --git a/google/cloud/internal/oauth2_api_key_credentials.cc b/google/cloud/internal/oauth2_api_key_credentials.cc index aee0f3386f16c..7e8b975cc9b29 100644 --- a/google/cloud/internal/oauth2_api_key_credentials.cc +++ b/google/cloud/internal/oauth2_api_key_credentials.cc @@ -27,12 +27,9 @@ StatusOr ApiKeyCredentials::GetToken( return AccessToken{std::string{}, tp}; } -StatusOr> -ApiKeyCredentials::AuthenticationHeaders(std::chrono::system_clock::time_point, - std::string_view) { - std::vector headers; - headers.emplace_back("x-goog-api-key", api_key_); - return headers; +StatusOr ApiKeyCredentials::Authorization( + std::chrono::system_clock::time_point) { + return rest_internal::HttpHeader{"x-goog-api-key", api_key_}; } GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/internal/oauth2_api_key_credentials.h b/google/cloud/internal/oauth2_api_key_credentials.h index 1ab7141d46ece..16258d9e3d753 100644 --- a/google/cloud/internal/oauth2_api_key_credentials.h +++ b/google/cloud/internal/oauth2_api_key_credentials.h @@ -37,9 +37,8 @@ class ApiKeyCredentials : public oauth2_internal::Credentials { StatusOr GetToken( std::chrono::system_clock::time_point tp) override; - StatusOr> AuthenticationHeaders( - std::chrono::system_clock::time_point, - std::string_view endpoint) override; + StatusOr Authorization( + std::chrono::system_clock::time_point tp) override; private: std::string api_key_; diff --git a/google/cloud/internal/oauth2_cached_credentials.cc b/google/cloud/internal/oauth2_cached_credentials.cc index 8ae00341c43df..11d81c5fdf18f 100644 --- a/google/cloud/internal/oauth2_cached_credentials.cc +++ b/google/cloud/internal/oauth2_cached_credentials.cc @@ -83,6 +83,11 @@ StatusOr CachedCredentials::project_id( return impl_->project_id(options); } +Credentials::AllowedLocationsRequestType +CachedCredentials::AllowedLocationsRequest() const { + return impl_->AllowedLocationsRequest(); +} + GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_cached_credentials.h b/google/cloud/internal/oauth2_cached_credentials.h index 9cd0bd528db96..ee8e18e9eb8ba 100644 --- a/google/cloud/internal/oauth2_cached_credentials.h +++ b/google/cloud/internal/oauth2_cached_credentials.h @@ -54,6 +54,8 @@ class CachedCredentials : public Credentials { StatusOr universe_domain(Options const& options) const override; StatusOr project_id() const override; StatusOr project_id(Options const& options) const override; + Credentials::AllowedLocationsRequestType AllowedLocationsRequest() + const override; private: std::shared_ptr impl_; diff --git a/google/cloud/internal/oauth2_compute_engine_credentials.cc b/google/cloud/internal/oauth2_compute_engine_credentials.cc index 4113e07fb3355..ac9cb5d2175f5 100644 --- a/google/cloud/internal/oauth2_compute_engine_credentials.cc +++ b/google/cloud/internal/oauth2_compute_engine_credentials.cc @@ -239,6 +239,16 @@ StatusOr ComputeEngineCredentials::project_id( return RetrieveProjectId(lk, options); } +Credentials::AllowedLocationsRequestType +ComputeEngineCredentials::AllowedLocationsRequest() const { + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + return ServiceAccountAllowedLocationsRequest{AccountEmail()}; +#else + return std::monostate{}; +#endif +} + StatusOr ComputeEngineCredentials::RetrieveUniverseDomain( std::lock_guard const&, Options const& options) const { // Fetch the universe domain only once. diff --git a/google/cloud/internal/oauth2_compute_engine_credentials.h b/google/cloud/internal/oauth2_compute_engine_credentials.h index 9ead58012e4c9..add952892db64 100644 --- a/google/cloud/internal/oauth2_compute_engine_credentials.h +++ b/google/cloud/internal/oauth2_compute_engine_credentials.h @@ -118,6 +118,8 @@ class ComputeEngineCredentials : public Credentials { StatusOr project_id( google::cloud::Options const& options) const override; + AllowedLocationsRequestType AllowedLocationsRequest() const override; + /** * Returns the email or alias of this credential's service account. * diff --git a/google/cloud/internal/oauth2_compute_engine_credentials_test.cc b/google/cloud/internal/oauth2_compute_engine_credentials_test.cc index 7edb731392d14..d9c7042972280 100644 --- a/google/cloud/internal/oauth2_compute_engine_credentials_test.cc +++ b/google/cloud/internal/oauth2_compute_engine_credentials_test.cc @@ -53,6 +53,7 @@ using ::testing::Property; using ::testing::Return; using ::testing::UnorderedElementsAre; using ::testing::UnorderedElementsAreArray; +using ::testing::VariantWith; using MockHttpClientFactory = ::testing::MockFunction( @@ -387,6 +388,10 @@ TEST(ComputeEngineCredentialsTest, FailedRefresh) { HasSubstr("Could not find all required fields"))); } +MATCHER_P(RequestServiceAccountEmailIs, email, "has service account email") { + return email == arg.service_account_email; +} + /// @test Verify that we can force a refresh of the service account email. TEST(ComputeEngineCredentialsTest, AccountEmail) { auto const alias = std::string{"default"}; @@ -416,6 +421,15 @@ TEST(ComputeEngineCredentialsTest, AccountEmail) { auto refreshed_email = credentials.AccountEmail(); EXPECT_EQ(email, refreshed_email); EXPECT_EQ(credentials.service_account_email(), refreshed_email); + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith( + RequestServiceAccountEmailIs(email))); +#else + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith(std::monostate())); +#endif } auto expected_universe_domain_request = []() { diff --git a/google/cloud/internal/oauth2_credentials.cc b/google/cloud/internal/oauth2_credentials.cc index 23301be9a1a44..4f85f961640f5 100644 --- a/google/cloud/internal/oauth2_credentials.cc +++ b/google/cloud/internal/oauth2_credentials.cc @@ -31,8 +31,9 @@ Credentials::AuthenticationHeaders(std::chrono::system_clock::time_point tp, if (!authorization->empty()) headers.push_back(*std::move(authorization)); auto allowed_locations = AllowedLocations(tp, endpoint); - // Not all credential types support the x-allowed-locations header. For those - // that do, if there is a problem retrieving the header, omit the header. + // Not all credential types support the x-allowed-locations header. For + // those that do, if there is a problem retrieving the header, omit the + // header. if (allowed_locations.ok() && !allowed_locations->empty()) { headers.push_back(*std::move(allowed_locations)); } diff --git a/google/cloud/internal/oauth2_credentials.h b/google/cloud/internal/oauth2_credentials.h index b52f285579543..7b8ece8c00ed0 100644 --- a/google/cloud/internal/oauth2_credentials.h +++ b/google/cloud/internal/oauth2_credentials.h @@ -22,6 +22,7 @@ #include "google/cloud/version.h" #include #include +#include #include namespace google { @@ -29,6 +30,19 @@ namespace cloud { namespace oauth2_internal { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +struct ServiceAccountAllowedLocationsRequest { + std::string service_account_email; +}; + +struct WorkloadIdentityAllowedLocationsRequest { + std::string project_id; + std::string pool_id; +}; + +struct WorkforceIdentityAllowedLocationsRequest { + std::string pool_id; +}; + /** * Interface for OAuth 2.0 credentials for use with Google's Unified Auth Client * (GUAC) library. Internally, GUAC credentials are mapped to the appropriate @@ -69,9 +83,8 @@ class Credentials { * @param endpoint the endpoint of the GCP service the RPC request will be * sent to. */ - virtual StatusOr> - AuthenticationHeaders(std::chrono::system_clock::time_point tp, - std::string_view endpoint); + StatusOr> AuthenticationHeaders( + std::chrono::system_clock::time_point tp, std::string_view endpoint); /** * Try to sign @p string_to_sign using @p service_account. @@ -160,6 +173,20 @@ class Credentials { */ virtual StatusOr GetToken( std::chrono::system_clock::time_point tp) = 0; + + using AllowedLocationsRequestType = + std::variant; + /** + * Obtains the request type from the underlying credential, if supported. + * + * Not all credential types support the `x-allowed-locations` header, but + * those that do vary in the data needed to format the request to IAM. + */ + virtual AllowedLocationsRequestType AllowedLocationsRequest() const { + return std::monostate{}; + } }; GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/internal/oauth2_external_account_credentials.cc b/google/cloud/internal/oauth2_external_account_credentials.cc index a9ab92ff1459a..aacb3277f51a4 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.cc +++ b/google/cloud/internal/oauth2_external_account_credentials.cc @@ -25,6 +25,7 @@ #include "google/cloud/internal/rest_client.h" #include "absl/strings/str_cat.h" #include +#include namespace google { namespace cloud { @@ -49,8 +50,28 @@ StatusOr MakeExternalAccountTokenSource( GCP_ERROR_INFO().WithContext(ec)); } +absl::optional WorkloadIdentityFromAudience( + std::string const& audience) { + auto constexpr kPattern = + R"""(iam.googleapis.com/projects/([^/]+)/locations/global/workloadIdentityPools/([^/]+)/)"""; + static auto* re = new std::regex{kPattern, std::regex::optimize}; + std::smatch match; + if (!std::regex_search(audience, match, *re)) { + return absl::nullopt; + } + return WorkloadIdentityFederationInfo{match[1], match[2]}; +} + } // namespace +bool ExternalAccountInfo::IsWorkforceIdentityFederation() const { + return workforce_pool_user_project.has_value(); +} + +bool ExternalAccountInfo::IsWorkloadIdentityFederation() const { + return workload_info.has_value(); +} + /// Parse a JSON string with an external account configuration. StatusOr ParseExternalAccountConfiguration( std::string const& configuration, internal::ErrorContext const& ec) { @@ -70,6 +91,10 @@ StatusOr ParseExternalAccountConfiguration( auto audience = ValidateStringField(json, "audience", "credentials-file", ec); if (!audience) return std::move(audience).status(); + + // extract workload project_number and pool_id from audience, if it exists + auto workload_identity = WorkloadIdentityFromAudience(*audience); + auto subject_token_type = ValidateStringField(json, "subject_token_type", "credentials-file", ec); if (!subject_token_type) return std::move(subject_token_type).status(); @@ -108,7 +133,8 @@ StatusOr ParseExternalAccountConfiguration( *std::move(source), absl::nullopt, *std::move(universe_domain), - std::move(workforce_pool_user_project)}; + std::move(workforce_pool_user_project), + std::move(workload_identity)}; it = json.find("service_account_impersonation_url"); if (it == json.end()) return info; @@ -161,7 +187,7 @@ StatusOr ExternalAccountCredentials::GetToken( // Workforce Identity is handled at the org level and requires the userProject // header. Workload Identity is handled at the project level and doesn't // require the header. - if (info_.workforce_pool_user_project) { + if (info_.IsWorkforceIdentityFederation()) { form_data.emplace_back( "options", absl::StrCat(R"({"userProject": ")", *info_.workforce_pool_user_project, R"("})")); @@ -221,6 +247,22 @@ StatusOr ExternalAccountCredentials::GetToken( return AccessToken{*token, tp + std::chrono::seconds(*expires_in)}; } +Credentials::AllowedLocationsRequestType +ExternalAccountCredentials::AllowedLocationsRequest() const { + Credentials::AllowedLocationsRequestType request = std::monostate{}; + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + if (info_.IsWorkforceIdentityFederation()) { + request = WorkforceIdentityAllowedLocationsRequest{ + *info_.workforce_pool_user_project}; + } else if (info_.IsWorkloadIdentityFederation()) { + request = WorkloadIdentityAllowedLocationsRequest{ + info_.workload_info->project_id, info_.workload_info->pool_id}; + } +#endif + return request; +} + StatusOr ExternalAccountCredentials::GetTokenImpersonation( std::string const& access_token, internal::ErrorContext const& ec) { auto request = rest_internal::RestRequest(info_.impersonation_config->url); diff --git a/google/cloud/internal/oauth2_external_account_credentials.h b/google/cloud/internal/oauth2_external_account_credentials.h index efd9c425f800c..9340cc65eb8a9 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.h +++ b/google/cloud/internal/oauth2_external_account_credentials.h @@ -55,6 +55,11 @@ struct ExternalAccountImpersonationConfig { std::chrono::seconds token_lifetime; }; +struct WorkloadIdentityFederationInfo { + std::string project_id; + std::string pool_id; +}; + /** * An external account configuration. * @@ -69,6 +74,9 @@ struct ExternalAccountInfo { absl::optional impersonation_config; std::string universe_domain; absl::optional workforce_pool_user_project; + absl::optional workload_info; + bool IsWorkforceIdentityFederation() const; + bool IsWorkloadIdentityFederation() const; }; /// Parse a JSON string with an external account configuration. @@ -89,6 +97,8 @@ class ExternalAccountCredentials : public oauth2_internal::Credentials { return info_.universe_domain; } + AllowedLocationsRequestType AllowedLocationsRequest() const override; + private: StatusOr GetTokenImpersonation(std::string const& access_token, internal::ErrorContext const& ec); diff --git a/google/cloud/internal/oauth2_external_account_credentials_test.cc b/google/cloud/internal/oauth2_external_account_credentials_test.cc index d28a262df96b4..8958666524ecd 100644 --- a/google/cloud/internal/oauth2_external_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_external_account_credentials_test.cc @@ -54,6 +54,7 @@ using ::testing::Property; using ::testing::ResultOf; using ::testing::Return; using ::testing::UnorderedElementsAre; +using ::testing::VariantWith; using MockClientFactory = ::testing::MockFunction( @@ -135,6 +136,15 @@ struct TestOnlyOption { using Type = std::string; }; +MATCHER_P(WorkforceIdentityIs, pool_id, "has pool_id") { + return pool_id == arg.pool_id; +} + +MATCHER_P2(WorkloadIdentityIs, project_id, pool_id, + "has project_id and pool_id") { + return project_id == arg.project_id && pool_id == arg.pool_id; +} + TEST(ExternalAccount, ParseAwsSuccess) { // To simplify the test we provide all the parameters via environment // variables and avoid using imdsv2. @@ -180,6 +190,8 @@ TEST(ExternalAccount, ParseAwsSuccess) { EXPECT_EQ(actual->subject_token_type, kTestTokenType); EXPECT_EQ(actual->token_url, "test-token-url"); EXPECT_EQ(actual->universe_domain, kUniverseDomain); + EXPECT_THAT(actual->workload_info, + Optional(WorkloadIdentityIs("$PROJECT_NUMBER", "$POOL_ID"))); MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).Times(0); @@ -676,11 +688,20 @@ TEST(ExternalAccount, Working) { auto mock_source = [](HttpClientFactory const&, Options const&) { return make_status_or(internal::SubjectToken{"test-subject-token"}); }; - auto const info = - ExternalAccountInfo{"test-audience", "test-subject-token-type", - test_url, mock_source, - absl::nullopt, {}, - absl::nullopt}; + + auto constexpr kTestAudience = + "//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/" + "workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"; + + auto const info = ExternalAccountInfo{ + kTestAudience, + "test-subject-token-type", + test_url, + mock_source, + absl::nullopt, + {}, + absl::nullopt, + WorkloadIdentityFederationInfo{"$PROJECT_NUMBER", "$POOL_ID"}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call(make_expected_options())).WillOnce([&]() { @@ -693,7 +714,7 @@ TEST(ExternalAccount, Working) { Pair("requested_token_type", "urn:ietf:params:oauth:token-type:access_token"), Pair("scope", "https://www.googleapis.com/auth/cloud-platform"), - Pair("audience", "test-audience"), + Pair("audience", kTestAudience), Pair("subject_token_type", "test-subject-token-type"), Pair("subject_token", "test-subject-token"))); EXPECT_CALL(*mock, Post(_, expected_request, expected_payload)) @@ -710,6 +731,15 @@ TEST(ExternalAccount, Working) { ASSERT_STATUS_OK(access_token); EXPECT_EQ(access_token->expiration, now + expected_expires_in); EXPECT_EQ(access_token->token, expected_access_token); + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith( + WorkloadIdentityIs("$PROJECT_NUMBER", "$POOL_ID"))); +#else + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith(std::monostate{})); +#endif } TEST(ExternalAccount, WorkingWorkforceIdentity) { @@ -731,7 +761,8 @@ TEST(ExternalAccount, WorkingWorkforceIdentity) { mock_source, absl::nullopt, {}, - "project-id-or-name"}; + "project-id-or-name", + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call(make_expected_options())).WillOnce([&]() { @@ -762,6 +793,15 @@ TEST(ExternalAccount, WorkingWorkforceIdentity) { ASSERT_STATUS_OK(access_token); EXPECT_EQ(access_token->expiration, now + expected_expires_in); EXPECT_EQ(access_token->token, expected_access_token); + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith( + WorkforceIdentityIs("project-id-or-name"))); +#else + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith(std::monostate{})); +#endif } TEST(ExternalAccount, WorkingWithImpersonation) { @@ -803,6 +843,7 @@ TEST(ExternalAccount, WorkingWithImpersonation) { ExternalAccountImpersonationConfig{ impersonate_test_url, impersonate_test_lifetime}, {}, + absl::nullopt, absl::nullopt}; auto sts_client = [&] { @@ -875,7 +916,7 @@ TEST(ExternalAccount, HandleHttpError) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -914,7 +955,7 @@ TEST(ExternalAccount, HandleHttpPartialError) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -954,7 +995,7 @@ TEST(ExternalAccount, HandleNotJson) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -994,7 +1035,7 @@ TEST(ExternalAccount, HandleNotJsonObject) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1040,7 +1081,7 @@ TEST(ExternalAccount, MissingToken) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1075,7 +1116,7 @@ TEST(ExternalAccount, MissingIssuedTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1110,7 +1151,7 @@ TEST(ExternalAccount, MissingTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1145,7 +1186,7 @@ TEST(ExternalAccount, InvalidIssuedTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1182,7 +1223,7 @@ TEST(ExternalAccount, InvalidTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1220,7 +1261,7 @@ TEST(ExternalAccount, MissingExpiresIn) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1256,7 +1297,7 @@ TEST(ExternalAccount, InvalidExpiresIn) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt}; + absl::nullopt, absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); diff --git a/google/cloud/internal/oauth2_impersonate_service_account_credentials.cc b/google/cloud/internal/oauth2_impersonate_service_account_credentials.cc index a2da884ed20bc..ee1bc933f00ac 100644 --- a/google/cloud/internal/oauth2_impersonate_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_impersonate_service_account_credentials.cc @@ -146,11 +146,23 @@ ImpersonateServiceAccountCredentials::ImpersonateServiceAccountCredentials( ImpersonateServiceAccountCredentials::ImpersonateServiceAccountCredentials( google::cloud::internal::ImpersonateServiceAccountConfig const& config, std::shared_ptr stub) - : stub_(std::move(stub)), request_(MakeRequest(config)) {} + : stub_(std::move(stub)), + access_token_request_(MakeRequest(config)), + allowed_locations_request_({config.target_service_account()}) {} StatusOr ImpersonateServiceAccountCredentials::GetToken( std::chrono::system_clock::time_point /*tp*/) { - return stub_->GenerateAccessToken(request_); + return stub_->GenerateAccessToken(access_token_request_); +} + +Credentials::AllowedLocationsRequestType +ImpersonateServiceAccountCredentials::AllowedLocationsRequest() const { + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + return allowed_locations_request_; +#else + return std::monostate{}; +#endif } GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/internal/oauth2_impersonate_service_account_credentials.h b/google/cloud/internal/oauth2_impersonate_service_account_credentials.h index cdd9d82607fb6..c5d656ef7c589 100644 --- a/google/cloud/internal/oauth2_impersonate_service_account_credentials.h +++ b/google/cloud/internal/oauth2_impersonate_service_account_credentials.h @@ -66,9 +66,12 @@ class ImpersonateServiceAccountCredentials return stub_->universe_domain(options); } + AllowedLocationsRequestType AllowedLocationsRequest() const override; + private: std::shared_ptr stub_; - GenerateAccessTokenRequest request_; + GenerateAccessTokenRequest access_token_request_; + ServiceAccountAllowedLocationsRequest allowed_locations_request_; }; GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc b/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc index 19da1679f3257..9a54e4c7c4ae7 100644 --- a/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc @@ -36,6 +36,7 @@ using ::testing::ElementsAre; using ::testing::HasSubstr; using ::testing::Optional; using ::testing::Return; +using ::testing::VariantWith; auto constexpr kFullValidConfig = R"""({ "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa3@developer.gserviceaccount.com:generateAccessToken", @@ -165,6 +166,10 @@ class MockMinimalIamCredentialsRest : public MinimalIamCredentialsRest { (override, const)); }; +MATCHER_P(RequestServiceAccountEmailIs, email, "has service account email") { + return email == arg.service_account_email; +} + TEST(ImpersonateServiceAccountCredentialsTest, Basic) { auto const now = std::chrono::system_clock::now(); @@ -193,6 +198,16 @@ TEST(ImpersonateServiceAccountCredentialsTest, Basic) { token = under_test.GetToken(now + minutes(45)); ASSERT_THAT(token, StatusIs(StatusCode::kPermissionDenied)); + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + EXPECT_THAT( + under_test.AllowedLocationsRequest(), + VariantWith( + RequestServiceAccountEmailIs("test-only-invalid@test.invalid"))); +#else + EXPECT_THAT(under_test.AllowedLocationsRequest(), + VariantWith(std::monostate())); +#endif } TEST(ParseImpersonatedServiceAccountCredentialsWithoutAction, Success) { diff --git a/google/cloud/internal/oauth2_logging_credentials.cc b/google/cloud/internal/oauth2_logging_credentials.cc index f88b1efc89c73..8d62adb6a0fc8 100644 --- a/google/cloud/internal/oauth2_logging_credentials.cc +++ b/google/cloud/internal/oauth2_logging_credentials.cc @@ -96,6 +96,12 @@ StatusOr LoggingCredentials::project_id( return impl_->project_id(options); } +Credentials::AllowedLocationsRequestType +LoggingCredentials::AllowedLocationsRequest() const { + GCP_LOG(DEBUG) << __func__ << "(" << phase_ << ")"; + return impl_->AllowedLocationsRequest(); +} + GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_logging_credentials.h b/google/cloud/internal/oauth2_logging_credentials.h index 259f8baf03ffa..076a6bb6b881f 100644 --- a/google/cloud/internal/oauth2_logging_credentials.h +++ b/google/cloud/internal/oauth2_logging_credentials.h @@ -58,6 +58,8 @@ class LoggingCredentials : public Credentials { StatusOr universe_domain(Options const& options) const override; StatusOr project_id() const override; StatusOr project_id(Options const& options) const override; + Credentials::AllowedLocationsRequestType AllowedLocationsRequest() + const override; private: std::string phase_; diff --git a/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h b/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h index fda4f1f9bb4e6..95593624b618c 100644 --- a/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h +++ b/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h @@ -39,19 +39,6 @@ struct GenerateAccessTokenRequest { std::vector delegates; }; -struct ServiceAccountAllowedLocationsRequest { - std::string service_account_email; -}; - -struct WorkloadIdentityAllowedLocationsRequest { - std::string project_id; - std::string pool_id; -}; - -struct WorkforceIdentityAllowedLocationsRequest { - std::string pool_id; -}; - struct AllowedLocationsResponse { std::vector locations; std::string encoded_locations; diff --git a/google/cloud/internal/oauth2_service_account_credentials.cc b/google/cloud/internal/oauth2_service_account_credentials.cc index 849f572211bfe..9bfb7281eaf69 100644 --- a/google/cloud/internal/oauth2_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_service_account_credentials.cc @@ -370,6 +370,16 @@ StatusOr ServiceAccountCredentials::project_id( return project_id(); } +Credentials::AllowedLocationsRequestType +ServiceAccountCredentials::AllowedLocationsRequest() const { + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + return ServiceAccountAllowedLocationsRequest{info_.client_email}; +#else + return std::monostate{}; +#endif +} + bool ServiceAccountUseOAuth(ServiceAccountCredentialsInfo const& info) { // Custom universe domains are only supported with JWT, not OAuth tokens. if (info.universe_domain.has_value() && diff --git a/google/cloud/internal/oauth2_service_account_credentials.h b/google/cloud/internal/oauth2_service_account_credentials.h index 6a62388c18ec9..748af3e9903d5 100644 --- a/google/cloud/internal/oauth2_service_account_credentials.h +++ b/google/cloud/internal/oauth2_service_account_credentials.h @@ -293,6 +293,8 @@ class ServiceAccountCredentials : public oauth2_internal::Credentials { StatusOr project_id() const override; StatusOr project_id(Options const&) const override; + AllowedLocationsRequestType AllowedLocationsRequest() const override; + private: bool UseOAuth(); StatusOr GetTokenOAuth( diff --git a/google/cloud/internal/oauth2_service_account_credentials_test.cc b/google/cloud/internal/oauth2_service_account_credentials_test.cc index a8bd0b6e23e0d..07531618f20f5 100644 --- a/google/cloud/internal/oauth2_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_service_account_credentials_test.cc @@ -56,6 +56,7 @@ using ::testing::Not; using ::testing::Pair; using ::testing::Property; using ::testing::Return; +using ::testing::VariantWith; using MockHttpClientFactory = ::testing::MockFunction( @@ -138,6 +139,10 @@ std::string MakeUniverseDomainTestContents() { return json.dump(); } +MATCHER_P(RequestServiceAccountEmailIs, email, "has service account email") { + return email == arg.service_account_email; +} + void CheckInfoYieldsExpectedAssertion(ServiceAccountCredentialsInfo const& info, std::string const& assertion, std::time_t assertion_time) { @@ -181,6 +186,15 @@ void CheckInfoYieldsExpectedAssertion(ServiceAccountCredentialsInfo const& info, ASSERT_STATUS_OK(token); EXPECT_EQ(token->token, "access-token-value"); EXPECT_EQ(token->expiration, tp + std::chrono::seconds(1234)); + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith( + RequestServiceAccountEmailIs(kClientEmail))); +#else + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith(std::monostate())); +#endif } TEST(ServiceAccountCredentialsTest, ServiceAccountUseOAuth) { From 1ea2cd1c21e81d58166713f72603d63299cb1234 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Wed, 15 Apr 2026 12:52:40 -0400 Subject: [PATCH 2/3] use pool not project in workforce identity federation --- .../oauth2_external_account_credentials.cc | 48 +++++++++------ .../oauth2_external_account_credentials.h | 8 ++- ...auth2_external_account_credentials_test.cc | 60 +++++++++++-------- 3 files changed, 72 insertions(+), 44 deletions(-) diff --git a/google/cloud/internal/oauth2_external_account_credentials.cc b/google/cloud/internal/oauth2_external_account_credentials.cc index aacb3277f51a4..fb6d38a82f52a 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.cc +++ b/google/cloud/internal/oauth2_external_account_credentials.cc @@ -50,26 +50,39 @@ StatusOr MakeExternalAccountTokenSource( GCP_ERROR_INFO().WithContext(ec)); } -absl::optional WorkloadIdentityFromAudience( - std::string const& audience) { - auto constexpr kPattern = +std::variant +IdentityFederationFromAudience(std::string const& audience) { + auto constexpr kWorkloadPattern = R"""(iam.googleapis.com/projects/([^/]+)/locations/global/workloadIdentityPools/([^/]+)/)"""; - static auto* re = new std::regex{kPattern, std::regex::optimize}; + static auto* workload_re = + new std::regex{kWorkloadPattern, std::regex::optimize}; + + auto constexpr kWorkforcePattern = + R"""(iam.googleapis.com/locations/global/workforcePools/([^/]+)/)"""; + static auto* workforce_re = + new std::regex{kWorkforcePattern, std::regex::optimize}; + std::smatch match; - if (!std::regex_search(audience, match, *re)) { - return absl::nullopt; + if (std::regex_search(audience, match, *workload_re)) { + return WorkloadIdentityFederationInfo{match[1], match[2]}; } - return WorkloadIdentityFederationInfo{match[1], match[2]}; + if (std::regex_search(audience, match, *workforce_re)) { + return WorkforceIdentityFederationInfo{match[1]}; + } + return std::monostate{}; } } // namespace bool ExternalAccountInfo::IsWorkforceIdentityFederation() const { - return workforce_pool_user_project.has_value(); + return std::holds_alternative( + identity_federation_info); } bool ExternalAccountInfo::IsWorkloadIdentityFederation() const { - return workload_info.has_value(); + return std::holds_alternative( + identity_federation_info); } /// Parse a JSON string with an external account configuration. @@ -91,9 +104,7 @@ StatusOr ParseExternalAccountConfiguration( auto audience = ValidateStringField(json, "audience", "credentials-file", ec); if (!audience) return std::move(audience).status(); - - // extract workload project_number and pool_id from audience, if it exists - auto workload_identity = WorkloadIdentityFromAudience(*audience); + auto identity_federation = IdentityFederationFromAudience(*audience); auto subject_token_type = ValidateStringField(json, "subject_token_type", "credentials-file", ec); @@ -134,7 +145,7 @@ StatusOr ParseExternalAccountConfiguration( absl::nullopt, *std::move(universe_domain), std::move(workforce_pool_user_project), - std::move(workload_identity)}; + std::move(identity_federation)}; it = json.find("service_account_impersonation_url"); if (it == json.end()) return info; @@ -253,11 +264,14 @@ ExternalAccountCredentials::AllowedLocationsRequest() const { // TODO(#16079): Remove conditional and else clause when GA. #ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB if (info_.IsWorkforceIdentityFederation()) { - request = WorkforceIdentityAllowedLocationsRequest{ - *info_.workforce_pool_user_project}; + auto wif = std::get( + info_.identity_federation_info); + request = WorkforceIdentityAllowedLocationsRequest{wif.pool_id}; } else if (info_.IsWorkloadIdentityFederation()) { - request = WorkloadIdentityAllowedLocationsRequest{ - info_.workload_info->project_id, info_.workload_info->pool_id}; + auto wif = std::get( + info_.identity_federation_info); + request = + WorkloadIdentityAllowedLocationsRequest{wif.project_id, wif.pool_id}; } #endif return request; diff --git a/google/cloud/internal/oauth2_external_account_credentials.h b/google/cloud/internal/oauth2_external_account_credentials.h index 9340cc65eb8a9..c4f46d30a30d5 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.h +++ b/google/cloud/internal/oauth2_external_account_credentials.h @@ -55,6 +55,10 @@ struct ExternalAccountImpersonationConfig { std::chrono::seconds token_lifetime; }; +struct WorkforceIdentityFederationInfo { + std::string pool_id; +}; + struct WorkloadIdentityFederationInfo { std::string project_id; std::string pool_id; @@ -74,7 +78,9 @@ struct ExternalAccountInfo { absl::optional impersonation_config; std::string universe_domain; absl::optional workforce_pool_user_project; - absl::optional workload_info; + std::variant + identity_federation_info; bool IsWorkforceIdentityFederation() const; bool IsWorkloadIdentityFederation() const; }; diff --git a/google/cloud/internal/oauth2_external_account_credentials_test.cc b/google/cloud/internal/oauth2_external_account_credentials_test.cc index 8958666524ecd..4bb47dc7701c2 100644 --- a/google/cloud/internal/oauth2_external_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_external_account_credentials_test.cc @@ -190,8 +190,9 @@ TEST(ExternalAccount, ParseAwsSuccess) { EXPECT_EQ(actual->subject_token_type, kTestTokenType); EXPECT_EQ(actual->token_url, "test-token-url"); EXPECT_EQ(actual->universe_domain, kUniverseDomain); - EXPECT_THAT(actual->workload_info, - Optional(WorkloadIdentityIs("$PROJECT_NUMBER", "$POOL_ID"))); + EXPECT_THAT(actual->identity_federation_info, + VariantWith( + WorkloadIdentityIs("$PROJECT_NUMBER", "$POOL_ID"))); MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).Times(0); @@ -317,10 +318,13 @@ TEST(ExternalAccount, ParseWithImpersonationDefaultLifetimeSuccess) { std::chrono::seconds(3600)); } -TEST(ExternalAccount, ParseUserProjectSuccess) { +TEST(ExternalAccount, ParseWorkforceIdentityFederationSuccess) { + auto constexpr kWorkforceAudience = + "//iam.googleapis.com/locations/global/workforcePools/$POOL_ID/providers/" + "PROVIDER_ID"; auto const configuration = nlohmann::json{ {"type", "external_account"}, - {"audience", "test-audience"}, + {"audience", kWorkforceAudience}, {"subject_token_type", "test-subject-token-type"}, {"token_url", "test-token-url"}, {"credential_source", nlohmann::json{{"file", "/dev/null-test-only"}}}, @@ -331,11 +335,14 @@ TEST(ExternalAccount, ParseUserProjectSuccess) { auto const actual = ParseExternalAccountConfiguration(configuration.dump(), ec); ASSERT_STATUS_OK(actual); - EXPECT_EQ(actual->audience, "test-audience"); + EXPECT_EQ(actual->audience, kWorkforceAudience); EXPECT_EQ(actual->subject_token_type, "test-subject-token-type"); EXPECT_EQ(actual->token_url, "test-token-url"); EXPECT_THAT(actual->workforce_pool_user_project, Optional(std::string("project-id-or-name"))); + EXPECT_THAT(actual->identity_federation_info, + VariantWith( + WorkforceIdentityIs("$POOL_ID"))); } TEST(ExternalAccount, ParseNotJson) { @@ -755,14 +762,15 @@ TEST(ExternalAccount, WorkingWorkforceIdentity) { auto mock_source = [](HttpClientFactory const&, Options const&) { return make_status_or(internal::SubjectToken{"test-subject-token"}); }; - auto const info = ExternalAccountInfo{"test-audience", - "test-subject-token-type", - test_url, - mock_source, - absl::nullopt, - {}, - "project-id-or-name", - absl::nullopt}; + auto const info = + ExternalAccountInfo{"test-audience", + "test-subject-token-type", + test_url, + mock_source, + absl::nullopt, + {}, + "project-id-or-name", + WorkforceIdentityFederationInfo{"$POOL_ID"}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call(make_expected_options())).WillOnce([&]() { @@ -797,7 +805,7 @@ TEST(ExternalAccount, WorkingWorkforceIdentity) { #ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB EXPECT_THAT(credentials.AllowedLocationsRequest(), VariantWith( - WorkforceIdentityIs("project-id-or-name"))); + WorkforceIdentityIs("$POOL_ID"))); #else EXPECT_THAT(credentials.AllowedLocationsRequest(), VariantWith(std::monostate{})); @@ -844,7 +852,7 @@ TEST(ExternalAccount, WorkingWithImpersonation) { impersonate_test_url, impersonate_test_lifetime}, {}, absl::nullopt, - absl::nullopt}; + std::monostate{}}; auto sts_client = [&] { auto expected_sts_request = Property(&RestRequest::path, sts_test_url); @@ -916,7 +924,7 @@ TEST(ExternalAccount, HandleHttpError) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -955,7 +963,7 @@ TEST(ExternalAccount, HandleHttpPartialError) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -995,7 +1003,7 @@ TEST(ExternalAccount, HandleNotJson) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1035,7 +1043,7 @@ TEST(ExternalAccount, HandleNotJsonObject) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1081,7 +1089,7 @@ TEST(ExternalAccount, MissingToken) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1116,7 +1124,7 @@ TEST(ExternalAccount, MissingIssuedTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1151,7 +1159,7 @@ TEST(ExternalAccount, MissingTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1186,7 +1194,7 @@ TEST(ExternalAccount, InvalidIssuedTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1223,7 +1231,7 @@ TEST(ExternalAccount, InvalidTokenType) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1261,7 +1269,7 @@ TEST(ExternalAccount, MissingExpiresIn) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1297,7 +1305,7 @@ TEST(ExternalAccount, InvalidExpiresIn) { ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, absl::nullopt, {}, - absl::nullopt, absl::nullopt}; + absl::nullopt, std::monostate{}}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); From 8b63db56668d7b4bc1ad1ac6ba0eb7f60bd01963 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Wed, 15 Apr 2026 17:16:31 -0400 Subject: [PATCH 3/3] update workforce user project conditional --- google/cloud/internal/oauth2_external_account_credentials.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/google/cloud/internal/oauth2_external_account_credentials.cc b/google/cloud/internal/oauth2_external_account_credentials.cc index fb6d38a82f52a..c162b1d398719 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.cc +++ b/google/cloud/internal/oauth2_external_account_credentials.cc @@ -198,7 +198,8 @@ StatusOr ExternalAccountCredentials::GetToken( // Workforce Identity is handled at the org level and requires the userProject // header. Workload Identity is handled at the project level and doesn't // require the header. - if (info_.IsWorkforceIdentityFederation()) { + if (info_.IsWorkforceIdentityFederation() && + info_.workforce_pool_user_project.has_value()) { form_data.emplace_back( "options", absl::StrCat(R"({"userProject": ")", *info_.workforce_pool_user_project, R"("})"));