diff --git a/google/cloud/internal/external_account_integration_test.cc b/google/cloud/internal/external_account_integration_test.cc index 2f511828ca2a3..663079b222d03 100644 --- a/google/cloud/internal/external_account_integration_test.cc +++ b/google/cloud/internal/external_account_integration_test.cc @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include "google/cloud/backoff_policy.h" #include "google/cloud/common_options.h" #include "google/cloud/credentials.h" #include "google/cloud/internal/getenv.h" #include "google/cloud/internal/rest_client.h" +#include "google/cloud/internal/unified_rest_credentials.h" #include "google/cloud/testing_util/status_matchers.h" #include #include @@ -29,6 +31,70 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace { using ::google::cloud::internal::GetEnv; +using ::google::cloud::testing_util::IsOkAndHolds; + +auto constexpr kEndpointThatUsesRAB = "storage.googleapis.com"; + +MATCHER_P(NonEmptyHttpHeaderNameIs, header_name, "has non-empty header named") { + return header_name == arg.name() && !arg.EmptyValues(); +} + +StatusOr> RetryRestRequest( + std::function< + StatusOr>()> const& + request) { + auto backoff = google::cloud::ExponentialBackoffPolicy( + std::chrono::seconds(1), std::chrono::minutes(5), 2.0); + StatusOr> response; + for (auto i = 0; i != 10; ++i) { + response = request(); + if (response.ok()) return response; + std::this_thread::sleep_for(backoff.OnCompletion()); + } + return response; +} + +void HandleResponse(std::unique_ptr response, + std::string const& expected_kind) { + auto response_payload = std::move(*response).ExtractPayload(); + auto payload = rest_internal::ReadAll(std::move(response_payload)); + ASSERT_STATUS_OK(payload); + auto parsed = nlohmann::json::parse(*payload, nullptr, false); + ASSERT_TRUE(parsed.is_object()) << "parsed=" << parsed; + ASSERT_TRUE(parsed.contains("kind")) << "parsed=" << parsed; + EXPECT_EQ(parsed.value("kind", ""), expected_kind); +} + +void MakeStorageRpcCall(Options options) { + std::string endpoint = "https://storage.googleapis.com"; + auto client = + rest_internal::MakePooledRestClient(endpoint, std::move(options)); + rest_internal::RestRequest request; + request.SetPath("storage/v1/b/gcp-public-data-landsat"); + auto response = RetryRestRequest([&] { + rest_internal::RestContext context; + return client->Get(context, request); + }); + ASSERT_STATUS_OK(response); + HandleResponse(*std::move(response), "storage#bucket"); +} + +std::string GetExternalAccountCredentialsContents() { + for (auto const& var : + {"GOOGLE_CLOUD_CPP_REST_TEST_EXTERNAL_ACCOUNT_KEY_FILE", + "GOOGLE_APPLICATION_CREDENTIALS"}) { + auto path = internal::GetEnv(var); + if (!path.has_value() || path->empty()) continue; + std::ifstream is(*path); + auto contents = std::string{std::istreambuf_iterator{is}, {}}; + if (contents.empty()) continue; + auto parsed = nlohmann::json::parse(contents, nullptr, false); + if (parsed.is_object() && parsed.value("type", "") == "external_account") { + return contents; + } + } + return {}; +} TEST(ExternalAccountIntegrationTest, UrlSourced) { auto bucket = GetEnv("GOOGLE_CLOUD_CPP_TEST_WIF_BUCKET"); @@ -73,6 +139,41 @@ TEST(ExternalAccountIntegrationTest, UrlSourced) { } } // namespace + +TEST(ExternalAccountIntegrationTest, ExternalAccountCredentials) { + auto contents = GetExternalAccountCredentialsContents(); + if (contents.empty()) GTEST_SKIP(); + + ASSERT_NO_FATAL_FAILURE( + MakeStorageRpcCall(Options{}.set( + MakeExternalAccountCredentials(contents)))); +} + +TEST(ExternalAccountIntegrationTest, RABExternalAccountCredentials) { + auto contents = GetExternalAccountCredentialsContents(); + if (contents.empty()) GTEST_SKIP(); + + auto creds = MakeExternalAccountCredentials(contents); + auto creds_rest = rest_internal::MapCredentials(*creds); + + auto headers = creds_rest->AuthenticationHeaders( + std::chrono::system_clock::now(), kEndpointThatUsesRAB); + EXPECT_THAT(headers, + IsOkAndHolds(::testing::Contains( + NonEmptyHttpHeaderNameIs(std::string{"authorization"})))); + + // x-allowed-locations header is fetched asynchronously. + for (auto delay : {2, 3, 5}) { + std::this_thread::sleep_for(std::chrono::seconds(delay)); + headers = creds_rest->AuthenticationHeaders( + std::chrono::system_clock::now(), kEndpointThatUsesRAB); + if (headers.ok() && headers->size() > 1) break; + } + + EXPECT_THAT(headers, + IsOkAndHolds(::testing::Contains(NonEmptyHttpHeaderNameIs( + std::string{"x-allowed-locations"})))); +} // namespace GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_external_account_credentials.cc b/google/cloud/internal/oauth2_external_account_credentials.cc index c162b1d398719..98ebd24d6ecc1 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.cc +++ b/google/cloud/internal/oauth2_external_account_credentials.cc @@ -24,6 +24,7 @@ #include "google/cloud/internal/parse_rfc3339.h" #include "google/cloud/internal/rest_client.h" #include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" #include #include @@ -85,6 +86,60 @@ bool ExternalAccountInfo::IsWorkloadIdentityFederation() const { identity_federation_info); } +StatusOr> +GetExternalAccountImpersonationConfiguration( + nlohmann::json const& configuration, internal::ErrorContext const& ec) { + auto constexpr kDefaultImpersonationTokenLifetime = + std::chrono::seconds(3600); + auto impersonation_config = + StatusOr>(std::nullopt); + auto it = configuration.find("service_account_impersonation_url"); + if (it == configuration.end()) return impersonation_config; + if (!it->is_string()) { + return InvalidTypeError("service_account_impersonation_url", + "credentials-file", ec); + } + + // AIP-4117 https://google.aip.dev/auth/4117 specifies the + // service_account_impersonation_url ends with ":generateAccessToken". + std::string url = it->get(); + std::vector pieces = absl::StrSplit(url, '/'); + std::string email; + for (auto const& p : pieces) { + if (absl::StrContains(p, ":")) { + std::vector t = absl::StrSplit(p, ':'); + email = t.front(); + } + } + + if (email.empty()) { + return InvalidArgumentError( + "invalid service_account_impersonation_url; must conform to AIP-4117", + GCP_ERROR_INFO() + .WithMetadata("service_account_impersonation_url", url) + .WithContext(ec)); + } + + impersonation_config = ExternalAccountImpersonationConfig{ + std::move(url), std::move(email), kDefaultImpersonationTokenLifetime}; + + it = configuration.find("service_account_impersonation"); + if (it == configuration.end()) return impersonation_config; + if (!it->is_object()) { + return InvalidTypeError("service_account_impersonation", "credentials-file", + ec); + } + auto lifetime = ValidateIntField( + it.value(), "token_lifetime_seconds", + "credentials-file.service_account_impersonation", + static_cast(kDefaultImpersonationTokenLifetime.count()), + ec); + if (!lifetime) return std::move(lifetime).status(); + (*impersonation_config)->token_lifetime = std::chrono::seconds(*lifetime); + + return impersonation_config; +} + /// Parse a JSON string with an external account configuration. StatusOr ParseExternalAccountConfiguration( std::string const& configuration, internal::ErrorContext const& ec) { @@ -147,31 +202,11 @@ StatusOr ParseExternalAccountConfiguration( std::move(workforce_pool_user_project), std::move(identity_federation)}; - it = json.find("service_account_impersonation_url"); - if (it == json.end()) return info; - - auto constexpr kDefaultImpersonationTokenLifetime = - std::chrono::seconds(3600); - if (!it->is_string()) { - return InvalidTypeError("service_account_impersonation_url", - "credentials-file", ec); - } - info.impersonation_config = ExternalAccountImpersonationConfig{ - it->get(), kDefaultImpersonationTokenLifetime}; - it = json.find("service_account_impersonation"); - if (it == json.end()) return info; - if (!it->is_object()) { - return InvalidTypeError("service_account_impersonation", "credentials-file", - ec); - } - auto lifetime = ValidateIntField( - it.value(), "token_lifetime_seconds", - "credentials-file.service_account_impersonation", - static_cast(kDefaultImpersonationTokenLifetime.count()), - ec); - if (!lifetime) return std::move(lifetime).status(); - info.impersonation_config->token_lifetime = std::chrono::seconds(*lifetime); + auto impersonate_config = + GetExternalAccountImpersonationConfiguration(json, ec); + if (!impersonate_config.ok()) return impersonate_config.status(); + info.impersonation_config = *std::move(impersonate_config); return info; } @@ -264,7 +299,10 @@ 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()) { + if (info_.impersonation_config.has_value()) { + request = ServiceAccountAllowedLocationsRequest{ + info_.impersonation_config->email}; + } else if (info_.IsWorkforceIdentityFederation()) { auto wif = std::get( info_.identity_federation_info); request = WorkforceIdentityAllowedLocationsRequest{wif.pool_id}; diff --git a/google/cloud/internal/oauth2_external_account_credentials.h b/google/cloud/internal/oauth2_external_account_credentials.h index c4f46d30a30d5..b3e20ebeb0722 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.h +++ b/google/cloud/internal/oauth2_external_account_credentials.h @@ -21,8 +21,10 @@ #include "google/cloud/internal/rest_client.h" #include "google/cloud/options.h" #include "google/cloud/version.h" +#include #include #include +#include namespace google { namespace cloud { @@ -52,6 +54,7 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN */ struct ExternalAccountImpersonationConfig { std::string url; + std::string email; std::chrono::seconds token_lifetime; }; @@ -75,9 +78,9 @@ struct ExternalAccountInfo { std::string subject_token_type; std::string token_url; ExternalAccountTokenSource token_source; - absl::optional impersonation_config; + std::optional impersonation_config; std::string universe_domain; - absl::optional workforce_pool_user_project; + std::optional workforce_pool_user_project; std::variant identity_federation_info; @@ -85,6 +88,10 @@ struct ExternalAccountInfo { bool IsWorkloadIdentityFederation() const; }; +StatusOr> +GetExternalAccountImpersonationConfiguration( + nlohmann::json const& configuration, internal::ErrorContext const& ec); + /// Parse a JSON string with an external account configuration. StatusOr ParseExternalAccountConfiguration( std::string const& configuration, 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 4bb47dc7701c2..5e74a0a7f2904 100644 --- a/google/cloud/internal/oauth2_external_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_external_account_credentials_test.cc @@ -20,6 +20,7 @@ #include "google/cloud/testing_util/mock_rest_response.h" #include "google/cloud/testing_util/scoped_environment.h" #include "google/cloud/testing_util/status_matchers.h" +#include "absl/strings/str_cat.h" #include #include #include @@ -640,6 +641,24 @@ TEST(ExternalAccount, ParseInvalidServiceAccountImpersonationUrl) { "invalid type for `service_account_impersonation_url` field"))); } +TEST(ExternalAccount, ParseInvalidServiceAccountImpersonationUrlFormat) { + auto const configuration = nlohmann::json{ + {"type", "external_account"}, + {"audience", "test-audience"}, + {"subject_token_type", "test-subject-token-type"}, + {"token_url", "test-token-url"}, + {"credential_source", nlohmann::json{{"file", "/dev/null-test-only"}}}, + {"service_account_impersonation_url", + "impersonation-url-no-email"}, // should be string + }; + auto ec = internal::ErrorContext( + {{"program", "test"}, {"full-configuration", configuration.dump()}}); + auto const actual = + ParseExternalAccountConfiguration(configuration.dump(), ec); + EXPECT_THAT(actual, StatusIs(StatusCode::kInvalidArgument, + HasSubstr("must conform to AIP-4117"))); +} + TEST(ExternalAccount, ParseInvalidServiceAccountLifetime) { auto const configuration = nlohmann::json{ {"type", "external_account"}, @@ -647,7 +666,8 @@ TEST(ExternalAccount, ParseInvalidServiceAccountLifetime) { {"subject_token_type", "test-subject-token-type"}, {"token_url", "test-token-url"}, {"credential_source", nlohmann::json{{"file", "/dev/null-test-only"}}}, - {"service_account_impersonation_url", "test-impersonation-url"}, + {"service_account_impersonation_url", + "test-impersonation-url/email@foo:verb"}, {"service_account_impersonation", nlohmann::json{ {"token_lifetime_seconds", true}, // should be numeric @@ -812,6 +832,10 @@ TEST(ExternalAccount, WorkingWorkforceIdentity) { #endif } +MATCHER_P(RequestServiceAccountEmailIs, email, "has service account email") { + return email == arg.service_account_email; +} + TEST(ExternalAccount, WorkingWithImpersonation) { auto const sts_test_url = std::string{"https://sts.example.com/"}; auto const sts_access_token = std::string{"test-sts-access-token"}; @@ -822,8 +846,10 @@ TEST(ExternalAccount, WorkingWithImpersonation) { {"issued_token_type", "urn:ietf:params:oauth:token-type:access_token"}, {"token_type", "Bearer"}, }; - auto const impersonate_test_url = - std::string{"https://iamcredentials.example.com/test-account"}; + auto const impersonate_test_email = std::string{"test-account@foo"}; + auto const impersonate_test_url = absl::StrCat( + "https://iamcredentials.example.com/", impersonate_test_email, ":verb"); + ; auto const impersonate_test_lifetime = std::chrono::seconds(2345); auto const impersonate_access_token = std::string{"test-access-token"}; auto const impersonate_request_payload = nlohmann::json{ @@ -849,7 +875,8 @@ TEST(ExternalAccount, WorkingWithImpersonation) { sts_test_url, mock_source, ExternalAccountImpersonationConfig{ - impersonate_test_url, impersonate_test_lifetime}, + impersonate_test_url, impersonate_test_email, + impersonate_test_lifetime}, {}, absl::nullopt, std::monostate{}}; @@ -905,6 +932,15 @@ TEST(ExternalAccount, WorkingWithImpersonation) { ASSERT_STATUS_OK(access_token); EXPECT_EQ(access_token->expiration, impersonate_expire_time); EXPECT_EQ(access_token->token, impersonate_access_token); + // TODO(#16079): Remove conditional and else clause when GA. +#ifdef GOOGLE_CLOUD_CPP_TESTING_ENABLE_RAB + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith( + RequestServiceAccountEmailIs(impersonate_test_email))); +#else + EXPECT_THAT(credentials.AllowedLocationsRequest(), + VariantWith(std::monostate{})); +#endif } TEST(ExternalAccount, HandleHttpError) {