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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions google/cloud/internal/external_account_integration_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gmock/gmock.h>
#include <nlohmann/json.hpp>
Expand All @@ -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<std::unique_ptr<rest_internal::RestResponse>> RetryRestRequest(
std::function<
StatusOr<std::unique_ptr<rest_internal::RestResponse>>()> const&
request) {
auto backoff = google::cloud::ExponentialBackoffPolicy(
std::chrono::seconds(1), std::chrono::minutes(5), 2.0);
StatusOr<std::unique_ptr<rest_internal::RestResponse>> 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<rest_internal::RestResponse> 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<char>{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");
Expand Down Expand Up @@ -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<UnifiedCredentialsOption>(
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
Expand Down
88 changes: 63 additions & 25 deletions google/cloud/internal/oauth2_external_account_credentials.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <nlohmann/json.hpp>
#include <regex>

Expand Down Expand Up @@ -85,6 +86,60 @@ bool ExternalAccountInfo::IsWorkloadIdentityFederation() const {
identity_federation_info);
}

StatusOr<std::optional<ExternalAccountImpersonationConfig>>
GetExternalAccountImpersonationConfiguration(
nlohmann::json const& configuration, internal::ErrorContext const& ec) {
auto constexpr kDefaultImpersonationTokenLifetime =
std::chrono::seconds(3600);
auto impersonation_config =
StatusOr<std::optional<ExternalAccountImpersonationConfig>>(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 "<email>:generateAccessToken".
std::string url = it->get<std::string>();
std::vector<std::string_view> pieces = absl::StrSplit(url, '/');
std::string email;
for (auto const& p : pieces) {
if (absl::StrContains(p, ":")) {
std::vector<std::string_view> 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<std::int32_t>(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<ExternalAccountInfo> ParseExternalAccountConfiguration(
std::string const& configuration, internal::ErrorContext const& ec) {
Expand Down Expand Up @@ -147,31 +202,11 @@ StatusOr<ExternalAccountInfo> 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<std::string>(), 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<std::int32_t>(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;
}

Expand Down Expand Up @@ -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<WorkforceIdentityFederationInfo>(
info_.identity_federation_info);
request = WorkforceIdentityAllowedLocationsRequest{wif.pool_id};
Expand Down
11 changes: 9 additions & 2 deletions google/cloud/internal/oauth2_external_account_credentials.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
#include "google/cloud/internal/rest_client.h"
#include "google/cloud/options.h"
#include "google/cloud/version.h"
#include <nlohmann/json_fwd.hpp>
#include <functional>
#include <memory>
#include <optional>

namespace google {
namespace cloud {
Expand Down Expand Up @@ -52,6 +54,7 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN
*/
struct ExternalAccountImpersonationConfig {
std::string url;
std::string email;
std::chrono::seconds token_lifetime;
};

Expand All @@ -75,16 +78,20 @@ struct ExternalAccountInfo {
std::string subject_token_type;
std::string token_url;
ExternalAccountTokenSource token_source;
absl::optional<ExternalAccountImpersonationConfig> impersonation_config;
std::optional<ExternalAccountImpersonationConfig> impersonation_config;
std::string universe_domain;
absl::optional<std::string> workforce_pool_user_project;
std::optional<std::string> workforce_pool_user_project;
std::variant<std::monostate, WorkforceIdentityFederationInfo,
WorkloadIdentityFederationInfo>
identity_federation_info;
bool IsWorkforceIdentityFederation() const;
bool IsWorkloadIdentityFederation() const;
};

StatusOr<std::optional<ExternalAccountImpersonationConfig>>
GetExternalAccountImpersonationConfiguration(
nlohmann::json const& configuration, internal::ErrorContext const& ec);

/// Parse a JSON string with an external account configuration.
StatusOr<ExternalAccountInfo> ParseExternalAccountConfiguration(
std::string const& configuration, internal::ErrorContext const& ec);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gmock/gmock.h>
#include <nlohmann/json.hpp>
#include <algorithm>
Expand Down Expand Up @@ -640,14 +641,33 @@ 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"},
{"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", "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
Expand Down Expand Up @@ -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"};
Expand All @@ -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{
Expand All @@ -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{}};
Expand Down Expand Up @@ -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<ServiceAccountAllowedLocationsRequest>(
RequestServiceAccountEmailIs(impersonate_test_email)));
#else
EXPECT_THAT(credentials.AllowedLocationsRequest(),
VariantWith<std::monostate>(std::monostate{}));
#endif
}

TEST(ExternalAccount, HandleHttpError) {
Expand Down
Loading