From 153a8a317180b3cb7e9ecfd9c598f11b685e7b30 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Mon, 20 Apr 2026 18:33:05 -0400 Subject: [PATCH 1/9] oauth2 wip --- .typos.toml | 1 + google/cloud/credentials.h | 10 + .../cloud/google_cloud_cpp_rest_internal.bzl | 2 + .../google_cloud_cpp_rest_internal.cmake | 3 + ...gle_cloud_cpp_rest_internal_unit_tests.bzl | 1 + ...oauth2_gdch_service_account_credentials.cc | 279 +++++ .../oauth2_gdch_service_account_credentials.h | 216 ++++ ...2_gdch_service_account_credentials_test.cc | 973 ++++++++++++++++++ .../internal/oauth2_google_credentials.cc | 9 + 9 files changed, 1494 insertions(+) create mode 100644 google/cloud/internal/oauth2_gdch_service_account_credentials.cc create mode 100644 google/cloud/internal/oauth2_gdch_service_account_credentials.h create mode 100644 google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc diff --git a/.typos.toml b/.typos.toml index 81e8765a472c0..37253cfe06a6e 100644 --- a/.typos.toml +++ b/.typos.toml @@ -41,6 +41,7 @@ extend-exclude = [ "ci/etc/invalidated-keyfile.json", "google/cloud/internal/curl_rest_client_integration_test.cc", "google/cloud/internal/grpc_service_account_authentication_test.cc", + "google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc", "google/cloud/internal/oauth2_google_credentials_test.cc", "google/cloud/internal/oauth2_service_account_credentials_test.cc", "google/cloud/internal/rest_client_integration_test.cc", diff --git a/google/cloud/credentials.h b/google/cloud/credentials.h index 29c884c38d640..a58f81483d5ba 100644 --- a/google/cloud/credentials.h +++ b/google/cloud/credentials.h @@ -475,6 +475,16 @@ struct ScopesOption { using Type = std::vector; }; +/** + * Configure the audience for `MakeGDCHServiceAccountCredentials`. + * + * @ingroup options + * @ingroup guac + */ +struct AudienceOption { + using Type = std::string; +}; + /** * Overrides the subject for `MakeServiceAccountCredentials` and * `MakeServiceAccountCredentialsFromFile()`. diff --git a/google/cloud/google_cloud_cpp_rest_internal.bzl b/google/cloud/google_cloud_cpp_rest_internal.bzl index 81603086f1e8e..5d0a4f8e417ac 100644 --- a/google/cloud/google_cloud_cpp_rest_internal.bzl +++ b/google/cloud/google_cloud_cpp_rest_internal.bzl @@ -47,6 +47,7 @@ google_cloud_cpp_rest_internal_hdrs = [ "internal/oauth2_error_credentials.h", "internal/oauth2_external_account_credentials.h", "internal/oauth2_external_account_token_source.h", + "internal/oauth2_gdch_service_account_credentials.h", "internal/oauth2_google_application_default_credentials_file.h", "internal/oauth2_google_credentials.h", "internal/oauth2_http_client_factory.h", @@ -108,6 +109,7 @@ google_cloud_cpp_rest_internal_srcs = [ "internal/oauth2_decorate_credentials.cc", "internal/oauth2_error_credentials.cc", "internal/oauth2_external_account_credentials.cc", + "internal/oauth2_gdch_service_account_credentials.cc", "internal/oauth2_google_application_default_credentials_file.cc", "internal/oauth2_google_credentials.cc", "internal/oauth2_impersonate_service_account_credentials.cc", diff --git a/google/cloud/google_cloud_cpp_rest_internal.cmake b/google/cloud/google_cloud_cpp_rest_internal.cmake index 41926d4906226..c2fa155d7ad9e 100644 --- a/google/cloud/google_cloud_cpp_rest_internal.cmake +++ b/google/cloud/google_cloud_cpp_rest_internal.cmake @@ -79,6 +79,8 @@ add_library( internal/oauth2_external_account_credentials.cc internal/oauth2_external_account_credentials.h internal/oauth2_external_account_token_source.h + internal/oauth2_gdch_service_account_credentials.cc + internal/oauth2_gdch_service_account_credentials.h internal/oauth2_google_application_default_credentials_file.cc internal/oauth2_google_application_default_credentials_file.h internal/oauth2_google_credentials.cc @@ -280,6 +282,7 @@ if (BUILD_TESTING) internal/oauth2_compute_engine_credentials_test.cc internal/oauth2_credentials_test.cc internal/oauth2_external_account_credentials_test.cc + internal/oauth2_gdch_service_account_credentials_test.cc internal/oauth2_google_application_default_credentials_file_test.cc internal/oauth2_google_credentials_test.cc internal/oauth2_impersonate_service_account_credentials_test.cc diff --git a/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl b/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl index 97315183034dc..003a9fe1d2080 100644 --- a/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl +++ b/google/cloud/google_cloud_cpp_rest_internal_unit_tests.bzl @@ -45,6 +45,7 @@ google_cloud_cpp_rest_internal_unit_tests = [ "internal/oauth2_compute_engine_credentials_test.cc", "internal/oauth2_credentials_test.cc", "internal/oauth2_external_account_credentials_test.cc", + "internal/oauth2_gdch_service_account_credentials_test.cc", "internal/oauth2_google_application_default_credentials_file_test.cc", "internal/oauth2_google_credentials_test.cc", "internal/oauth2_impersonate_service_account_credentials_test.cc", diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc new file mode 100644 index 0000000000000..68c9663cfbde4 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -0,0 +1,279 @@ +// Copyright 2026 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. + +#include "google/cloud/internal/oauth2_gdch_service_account_credentials.h" +#include "google/cloud/credentials.h" +#include "google/cloud/internal/getenv.h" +#include "google/cloud/internal/make_jwt_assertion.h" +#include "google/cloud/internal/make_status.h" +#include "google/cloud/internal/oauth2_google_credentials.h" +#include "google/cloud/internal/oauth2_universe_domain.h" +#include "google/cloud/internal/parse_service_account_p12_file.h" +#include "google/cloud/internal/rest_response.h" +#include "google/cloud/internal/sign_using_sha256.h" +#include "absl/strings/str_join.h" +#include "absl/strings/str_replace.h" +#include +#include +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +using ::google::cloud::internal::MakeJWTAssertionNoThrow; + +StatusOr ParseGDCHServiceAccountCredentials( + std::string const& content, std::string const& source) { + auto credentials = nlohmann::json::parse(content, nullptr, false); + if (credentials.is_discarded()) { + return internal::InvalidArgumentError(absl::StrCat( + "Invalid GDCHServiceAccountCredentials, parsing failed on ", + "data loaded from ", source)); + } + + using Validator = + std::function; + using Store = std::function; + auto non_empty_field = [&](absl::string_view name, + nlohmann::json::iterator const& l) { + if (l == credentials.end()) return Status{}; + if (!l->get().empty()) return Status{}; + return internal::InvalidArgumentError( + absl::StrCat("Invalid GDCHServiceAccountCredentials, the ", name, + " field is empty on data loaded from ", source)); + }; + auto required_field = [&](absl::string_view name, + nlohmann::json::iterator const& l) { + if (l == credentials.end()) { + return internal::InvalidArgumentError( + absl::StrCat("Invalid GDCHServiceAccountCredentials, the ", name, + " field is missing on data loaded from ", source)); + } + return non_empty_field(name, l); + }; + + struct Field { + std::string name; + Validator validator; + Store store; + }; + std::vector fields{{"project_id", required_field, + [](GDCHServiceAccountCredentialsInfo& info, + nlohmann::json::iterator const& l) { + info.project_id = l->get(); + }}, + {"private_key_id", required_field, + [&](GDCHServiceAccountCredentialsInfo& info, + nlohmann::json::iterator const& l) { + if (l == credentials.end()) return; + info.private_key_id = l->get(); + }}, + {"private_key", required_field, + [](GDCHServiceAccountCredentialsInfo& info, + nlohmann::json::iterator const& l) { + info.private_key = l->get(); + }}, + {"name", required_field, + [&](GDCHServiceAccountCredentialsInfo& info, + nlohmann::json::iterator const& l) { + info.service_identity_name = + l->get(); + }}, + {"ca_cert_path", required_field, + [&](GDCHServiceAccountCredentialsInfo& info, + nlohmann::json::iterator const& l) { + info.ca_cert_path = l->get(); + }}, + {"token_uri", required_field, + [&](GDCHServiceAccountCredentialsInfo& info, + nlohmann::json::iterator const& l) { + info.token_uri = l->get(); + }}}; + + auto info = GDCHServiceAccountCredentialsInfo{}; + for (auto& f : fields) { + auto l = credentials.find(f.name); + if (l != credentials.end() && !l->is_string()) { + return internal::InvalidArgumentError(absl::StrCat( + "Invalid GDCHServiceAccountCredentials, the ", f.name, + " field is present and is not a string, on data loaded from ", + source)); + } + auto status = f.validator(f.name, l); + if (!status.ok()) return status; + f.store(info, l); + } + return info; +} + +std::pair GDCHAssertionComponentsFromInfo( + GDCHServiceAccountCredentialsInfo const& info, + std::chrono::system_clock::time_point now) { + nlohmann::json assertion_header = {{"alg", "RS256"}, {"typ", "JWT"}}; + if (!info.private_key_id.empty()) { + assertion_header["kid"] = info.private_key_id; + } + + auto expiration = now + GoogleOAuthAccessTokenLifetime(); + // As much as possible, do the time arithmetic using the std::chrono types. + // Convert to an integer only when we are dealing with timestamps since the + // epoch. Note that we cannot use `time_t` directly because that might be a + // floating point. + auto const now_from_epoch = + static_cast(std::chrono::system_clock::to_time_t(now)); + auto const expiration_from_epoch = static_cast( + std::chrono::system_clock::to_time_t(expiration)); + auto iss_sub_value = absl::StrCat("system:serviceaccount:", info.project_id, + ":", info.service_identity_name); + nlohmann::json assertion_payload = { + {"iss", iss_sub_value}, + {"sub", iss_sub_value}, + {"aud", info.token_uri}, + {"iat", now_from_epoch}, + // Resulting access token should expire after one hour. + {"exp", expiration_from_epoch}}; + + // Note: we don't move here as it would prevent copy elision. + return std::make_pair(assertion_header.dump(), assertion_payload.dump()); +} + +std::string MakeGDCHJWTAssertion(std::string const& header, + std::string const& payload, + std::string const& pem_contents) { + return internal::MakeJWTAssertionNoThrow(header, payload, pem_contents) + .value(); +} + +std::vector> +CreateGDCHServiceAccountRefreshPayload( + GDCHServiceAccountCredentialsInfo const& info, + std::chrono::system_clock::time_point now) { + auto [header, payload] = GDCHAssertionComponentsFromInfo(info, now); + return { + {"grant_type", "urn:ietf:params:oauth:token-type:token-exchange"}, + {"audience", info.audience}, + {"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"}, + {"subject_token", + MakeGDCHJWTAssertion(header, payload, info.private_key)}, + {"subject_token_type", "urn:k8s:params:oauth:token-type:serviceaccount"}}; +} + +StatusOr ParseGDCHServiceAccountRefreshResponse( + rest_internal::RestResponse& response, + std::chrono::system_clock::time_point now) { + auto status_code = response.StatusCode(); + auto payload = rest_internal::ReadAll(std::move(response).ExtractPayload()); + if (!payload.ok()) return std::move(payload).status(); + auto access_token = nlohmann::json::parse(*payload, nullptr, false); + if (access_token.is_discarded() || access_token.count("access_token") == 0 || + access_token.count("expires_in") == 0 || + access_token.count("token_type") == 0) { + auto error_payload = + *payload + + "Could not find all required fields in response (access_token," + " expires_in, token_type) while trying to obtain an access token for" + " GDCH service account credentials."; + return AsStatus(status_code, error_payload); + } + + auto expires_in = std::chrono::seconds(access_token.value("expires_in", 0)); + return AccessToken{access_token.value("access_token", ""), now + expires_in}; +} + +StatusOr> +CreateGDCHServiceAccountCredentialsFromJsonContents( + std::string const& contents, Options const& options, + HttpClientFactory client_factory) { + auto info = ParseGDCHServiceAccountCredentials(contents, "memory"); + if (!info) return info.status(); + // Verify this is usable before returning it. + auto const tp = std::chrono::system_clock::time_point{}; + auto const components = GDCHAssertionComponentsFromInfo(*info, tp); + auto jwt = MakeJWTAssertionNoThrow(components.first, components.second, + info->private_key); + if (!jwt) return jwt.status(); + return StatusOr>( + std::make_shared( + *info, options, std::move(client_factory))); +} + +StatusOr> +CreateGDCHServiceAccountCredentialsFromJsonFilePath( + std::string const& path, Options const& options, + HttpClientFactory client_factory) { + std::ifstream is(path); + if (!is.is_open()) { + // We use kUnknown here because we don't know if the file does not exist, or + // if we were unable to open it for some other reason. + return internal::UnknownError("Cannot open credentials file " + path, + GCP_ERROR_INFO()); + } + std::string contents(std::istreambuf_iterator{is}, {}); + return CreateGDCHServiceAccountCredentialsFromJsonContents( + std::move(contents), options, std::move(client_factory)); +} + +StatusOr> +CreateGDCHServiceAccountCredentialsFromFilePath( + std::string const& path, Options const& options, + HttpClientFactory client_factory) { + auto credentials = CreateGDCHServiceAccountCredentialsFromJsonFilePath( + path, options, client_factory); + return credentials; +} + +GDCHServiceAccountCredentials::GDCHServiceAccountCredentials( + GDCHServiceAccountCredentialsInfo info, Options options, + HttpClientFactory client_factory) + : info_(std::move(info)), + options_(internal::MergeOptions( + std::move(options), + Options{}.set( + info_.token_uri))), + client_factory_(std::move(client_factory)) { + if (options_.has()) { + info_.audience = options_.get(); + } +} + +StatusOr GDCHServiceAccountCredentials::GetToken( + std::chrono::system_clock::time_point tp) { + auto client = client_factory_(options_); + rest_internal::RestRequest request; + request.SetPath(info_.token_uri); + auto payload = CreateGDCHServiceAccountRefreshPayload(info_, tp); + rest_internal::RestContext context; + auto response = client->Post(context, request, payload); + if (!response) return std::move(response).status(); + if (IsHttpError(**response)) return AsStatus(std::move(**response)); + return ParseServiceAccountRefreshResponse(**response, tp); +} + +StatusOr GDCHServiceAccountCredentials::project_id() const { + return info_.project_id; +} + +StatusOr GDCHServiceAccountCredentials::project_id( + Options const&) const { + // project_id() is stored locally, so any retry options are unnecessary. + return project_id(); +} + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace oauth2_internal +} // namespace cloud +} // namespace google diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.h b/google/cloud/internal/oauth2_gdch_service_account_credentials.h new file mode 100644 index 0000000000000..ab82bcb78ffba --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -0,0 +1,216 @@ +// Copyright 2026 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. + +#ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_GDCH_SERVICE_ACCOUNT_CREDENTIALS_H +#define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_GDCH_SERVICE_ACCOUNT_CREDENTIALS_H + +#include "google/cloud/internal/oauth2_credential_constants.h" +#include "google/cloud/internal/oauth2_credentials.h" +#include "google/cloud/internal/oauth2_http_client_factory.h" +#include "google/cloud/status_or.h" +#include "google/cloud/version.h" +#include +#include +#include +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +/// Object to hold information used to instantiate an +/// GDCHServiceAccountCredentials. +struct GDCHServiceAccountCredentialsInfo { + // From json file + std::string project_id; + std::string private_key_id; + std::string private_key; + std::string service_identity_name; + std::string ca_cert_path; + std::string token_uri; + + // Additional data provided by the user. + std::string audience; +}; + +/// Parses a refresh response JSON string to create an access token. +StatusOr ParseGDCHServiceAccountRefreshResponse( + rest_internal::RestResponse& response, + std::chrono::system_clock::time_point now); + +/** + * Splits a GDCHServiceAccountCredentialsInfo into header and payload components + * and uses the current time to make a JWT assertion. + * + * @see + * https://cloud.google.com/endpoints/docs/frameworks/java/troubleshoot-jwt + * + * @see https://tools.ietf.org/html/rfc7523 + */ +std::pair GDCHAssertionComponentsFromInfo( + GDCHServiceAccountCredentialsInfo const& info, + std::chrono::system_clock::time_point now); + +/** + * Given a key and a JSON header and payload, creates a JWT assertion string. + * + * @see https://tools.ietf.org/html/rfc7519 + */ +std::string MakeGDCHJWTAssertion(std::string const& header, + std::string const& payload, + std::string const& pem_contents); + +/// Uses a GDCHServiceAccountCredentialsInfo and the current time to construct a +/// JWT assertion. The assertion combined with the grant type is used to create +/// the refresh payload. +std::vector> +CreateGDCHServiceAccountRefreshPayload( + GDCHServiceAccountCredentialsInfo const& info, + std::chrono::system_clock::time_point now); + +/** + * Creates a GDCHServiceAccountCredentials from a JSON string. + */ +StatusOr> +CreateGDCHServiceAccountCredentialsFromJsonContents( + std::string const& contents, Options const& options, + HttpClientFactory client_factory); + +/** + * Creates a GDCHServiceAccountCredentials from a JSON file at the specified + * path. + */ +StatusOr> +CreateGDCHServiceAccountCredentialsFromJsonFilePath( + std::string const& path, Options const& options, + HttpClientFactory client_factory); + +/** + * Creates a GDCHServiceAccountCredentials from a file at the specified path. + */ + +StatusOr> +CreateGDCHServiceAccountCredentialsFromFilePath( + std::string const& path, Options const& options, + HttpClientFactory client_factory); + +/** + * Implements GDCH service account credentials for REST clients. + * + * This class is not intended for use by application developers. But it is + * sufficiently complex that it deserves documentation for library developers. + * + * // TODO(sdhart): update this documentation + * This class description assumes that you are familiar with [service accounts], + * and [service account keys]. + * + * Use `ParseGDCHServiceAccountCredentials()` to parse a service account key. If + * the key is parsed successfully, you can create an instance of this class + * using its result. The service account key is never sent to Google for + * authentication. Instead, this class creates temporary access tokens, either + * self-signed JWT (as described in [aip/4111]), or OAuth access tokens (see + * [aip/4112]). + * + * To understand how these work it is useful to be familiar with [JWTs]. If you + * already know what these, feel free to skip this paragraph. JWTs are + * (relatively long) strings consisting of three (base64-encoded) components. + * The first two are base64 encoded JSON objects. These fields in these objects + * are often referred as "claims". For example, the `iat` (Issued At-Time) + * field, asserts or claims that the token was created at a certain time. The + * third component in a JWT is a signature created using some secret. In our + * case the signature is always created using the [RS256] signing algorithm. + * One of the claims is always the + * identifier for the service account key. Google Cloud has the public key + * associated with each service account key and can use this to verify that the + * JWT was actually signed by the service account key claimed by the JWT. + * + * With self-signed JWT, the token is created locally, the payload contains + * either an audience (`"aud"`) or scope (`"scope"`) claim (but not both) + * describing the service or services that the token grants access to. Setting + * a more restrictive scope or audience allows applications to create tokens + * that restrict the access for a service account. This class **only** supports + * scope-based self-signed JWTs. + * + * With OAuth-based access tokens the client library creates a JWT and makes a + * HTTP request to convert this JWT into an access token. In general, + * self-signed JWTs are preferred over OAuth-based access tokens. On the other + * hand, our implementation of OAuth-based access tokens has more flight hours, + * and has been tested in more environments (on-prem, VPC-SC with different + * restrictions, etc.). + * + * Since access tokens are relatively expensive to create this class caches the + * access tokens until they are about to expire. Use the + * `AuthenticationHeader()` to get the current access token. + * + * [aip/4111]: https://google.aip.dev/auth/4111 + * [aip/4112]: https://google.aip.dev/auth/4112 + * [RS256]: https://datatracker.ietf.org/doc/html/rfc7518 + * [JWTs]: https://en.wikipedia.org/wiki/JSON_Web_Token + * [service accounts]: + * https://cloud.google.com/iam/docs/overview#service_account + * + * [iam-overview]: + * https://cloud.google.com/iam/docs/overview + * + * [service account keys]: + * https://cloud.google.com/iam/docs/creating-managing-service-account-keys#iam-service-account-keys-create-cpp + */ +class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { + public: + /** + * Creates an instance of GDCHServiceAccountCredentials. + * + * @param rest_client a dependency injection point. It makes it possible to + * mock internal REST types. This should generally not be overridden + * except for testing. + * @param current_time_fn a dependency injection point to fetch the current + * time. This should generally not be overridden except for testing. + */ + GDCHServiceAccountCredentials(GDCHServiceAccountCredentialsInfo info, + Options options, + HttpClientFactory client_factory); + + /** + * Returns a key value pair for an "Authorization" header. + */ + StatusOr GetToken( + std::chrono::system_clock::time_point tp) override; + + std::string AccountEmail() const override { + return info_.service_identity_name; + } + + std::string KeyId() const override { return info_.private_key_id; } + + StatusOr project_id() const override; + StatusOr project_id(Options const&) const override; + + private: + GDCHServiceAccountCredentialsInfo info_; + Options options_; + HttpClientFactory client_factory_; +}; + +/// Parses the contents of a JSON keyfile into a +/// GDCHServiceAccountCredentialsInfo. +StatusOr ParseGDCHServiceAccountCredentials( + std::string const& content, std::string const& source); + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace oauth2_internal +} // namespace cloud +} // namespace google + +#endif // GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_GDCH_SERVICE_ACCOUNT_CREDENTIALS_H diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc new file mode 100644 index 0000000000000..5606aab4b8d10 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -0,0 +1,973 @@ +// Copyright 2026 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. + +#include "google/cloud/internal/oauth2_gdch_service_account_credentials.h" +#include "google/cloud/credentials.h" +#include "google/cloud/internal/base64_transforms.h" +#include "google/cloud/internal/oauth2_credential_constants.h" +#include "google/cloud/internal/oauth2_universe_domain.h" +#include "google/cloud/internal/random.h" +#include "google/cloud/internal/sign_using_sha256.h" +#include "google/cloud/testing_util/chrono_output.h" +#include "google/cloud/testing_util/mock_http_payload.h" +#include "google/cloud/testing_util/mock_rest_client.h" +#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_split.h" +#include +#include +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { +#if 0 +using ::google::cloud::internal::SignUsingSha256; +using ::google::cloud::internal::UrlsafeBase64Decode; +using ::google::cloud::rest_internal::RestRequest; +using ::google::cloud::testing_util::IsOkAndHolds; +using ::google::cloud::testing_util::MakeMockHttpPayloadSuccess; +using ::google::cloud::testing_util::MockRestClient; +using ::google::cloud::testing_util::MockRestResponse; +using ::google::cloud::testing_util::ScopedEnvironment; +using ::google::cloud::testing_util::StatusIs; +using ::testing::_; +using ::testing::AllOf; +using ::testing::ByMove; +using ::testing::Contains; +using ::testing::ElementsAreArray; +using ::testing::HasSubstr; +using ::testing::MatcherCast; +using ::testing::Not; +using ::testing::Pair; +using ::testing::Property; +using ::testing::Return; +using ::testing::VariantWith; + +using MockHttpClientFactory = + ::testing::MockFunction( + Options const&)>; + +constexpr char kScopeForTest0[] = + "https://www.googleapis.com/auth/devstorage.full_control"; +constexpr char kScopeForTest1[] = + "https://www.googleapis.com/auth/cloud-platform"; +constexpr std::time_t kFixedJwtTimestamp = 1530060324; +constexpr char kGrantParamUnescaped[] = + "urn:ietf:params:oauth:grant-type:jwt-bearer"; +constexpr char kSubjectForGrant[] = "user@foo.bar"; + +auto constexpr kProjectId = "test-only-project-id"; +auto constexpr kPrivateKeyId = "a1a111aa1111a11a11a11aa111a111a1a1111111"; +// This is an invalidated private key. It was created using the Google Cloud +// Platform console, but then the key (and service account) were deleted. +auto constexpr kPrivateKey = R"""(-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCltiF2oP3KJJ+S +tTc1McylY+TuAi3AdohX7mmqIjd8a3eBYDHs7FlnUrFC4CRijCr0rUqYfg2pmk4a +6TaKbQRAhWDJ7XD931g7EBvCtd8+JQBNWVKnP9ByJUaO0hWVniM50KTsWtyX3up/ +fS0W2R8Cyx4yvasE8QHH8gnNGtr94iiORDC7De2BwHi/iU8FxMVJAIyDLNfyk0hN +eheYKfIDBgJV2v6VaCOGWaZyEuD0FJ6wFeLybFBwibrLIBE5Y/StCrZoVZ5LocFP +T4o8kT7bU6yonudSCyNMedYmqHj/iF8B2UN1WrYx8zvoDqZk0nxIglmEYKn/6U7U +gyETGcW9AgMBAAECggEAC231vmkpwA7JG9UYbviVmSW79UecsLzsOAZnbtbn1VLT +Pg7sup7tprD/LXHoyIxK7S/jqINvPU65iuUhgCg3Rhz8+UiBhd0pCH/arlIdiPuD +2xHpX8RIxAq6pGCsoPJ0kwkHSw8UTnxPV8ZCPSRyHV71oQHQgSl/WjNhRi6PQroB +Sqc/pS1m09cTwyKQIopBBVayRzmI2BtBxyhQp9I8t5b7PYkEZDQlbdq0j5Xipoov +9EW0+Zvkh1FGNig8IJ9Wp+SZi3rd7KLpkyKPY7BK/g0nXBkDxn019cET0SdJOHQG +DiHiv4yTRsDCHZhtEbAMKZEpku4WxtQ+JjR31l8ueQKBgQDkO2oC8gi6vQDcx/CX +Z23x2ZUyar6i0BQ8eJFAEN+IiUapEeCVazuxJSt4RjYfwSa/p117jdZGEWD0GxMC ++iAXlc5LlrrWs4MWUc0AHTgXna28/vii3ltcsI0AjWMqaybhBTTNbMFa2/fV2OX2 +UimuFyBWbzVc3Zb9KAG4Y7OmJQKBgQC5324IjXPq5oH8UWZTdJPuO2cgRsvKmR/r +9zl4loRjkS7FiOMfzAgUiXfH9XCnvwXMqJpuMw2PEUjUT+OyWjJONEK4qGFJkbN5 +3ykc7p5V7iPPc7Zxj4mFvJ1xjkcj+i5LY8Me+gL5mGIrJ2j8hbuv7f+PWIauyjnp +Nx/0GVFRuQKBgGNT4D1L7LSokPmFIpYh811wHliE0Fa3TDdNGZnSPhaD9/aYyy78 +LkxYKuT7WY7UVvLN+gdNoVV5NsLGDa4cAV+CWPfYr5PFKGXMT/Wewcy1WOmJ5des +AgMC6zq0TdYmMBN6WpKUpEnQtbmh3eMnuvADLJWxbH3wCkg+4xDGg2bpAoGAYRNk +MGtQQzqoYNNSkfus1xuHPMA8508Z8O9pwKU795R3zQs1NAInpjI1sOVrNPD7Ymwc +W7mmNzZbxycCUL/yzg1VW4P1a6sBBYGbw1SMtWxun4ZbnuvMc2CTCh+43/1l+FHe +Mmt46kq/2rH2jwx5feTbOE6P6PINVNRJh/9BDWECgYEAsCWcH9D3cI/QDeLG1ao7 +rE2NcknP8N783edM07Z/zxWsIsXhBPY3gjHVz2LDl+QHgPWhGML62M0ja/6SsJW3 +YvLLIc82V7eqcVJTZtaFkuht68qu/Jn1ezbzJMJ4YXDYo1+KFi+2CAGR06QILb+I +lUtj+/nH3HDQjM4ltYfTPUg= +-----END PRIVATE KEY----- +)"""; +auto constexpr kClientEmail = + "test-only-email@test-only-project-id.iam.gserviceaccount.com"; +auto constexpr kClientId = "100000000000000000001"; +auto constexpr kAuthUri = "https://accounts.google.com/o/oauth2/auth"; +auto constexpr kTokenUri = "https://oauth2.googleapis.com/token"; +auto constexpr kAuthProviderX509CerlUrl = + "https://www.googleapis.com/oauth2/v1/certs"; +auto constexpr kClientX509CertUrl = + "https://www.googleapis.com/robot/v1/metadata/x509/" + "foo-email%40foo-project.iam.gserviceaccount.com"; + +nlohmann::json TestContents() { + return nlohmann::json{ + {"type", "service_account"}, + {"project_id", kProjectId}, + {"private_key_id", kPrivateKeyId}, + {"private_key", kPrivateKey}, + {"client_email", kClientEmail}, + {"client_id", kClientId}, + {"auth_uri", kAuthUri}, + {"token_uri", kTokenUri}, + {"auth_provider_x509_cert_url", kAuthProviderX509CerlUrl}, + {"client_x509_cert_url", kClientX509CertUrl}, + }; +} + +std::string MakeTestContents() { return TestContents().dump(); } + +auto constexpr kUniverseDomain = "test-domain.net"; +std::string MakeUniverseDomainTestContents() { + auto json = TestContents(); + json["universe_domain"] = kUniverseDomain; + 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) { + auto const post_response = std::string{R"""({ + "token_type": "Type", + "access_token": "access-token-value", + "expires_in": 1234 + })"""}; + + auto token_client = [=] { + using FormDataType = std::vector>; + auto mock = std::make_unique(); + auto expected_request = Property(&RestRequest::path, info.token_uri); + auto expected_form_data = MatcherCast( + AllOf(Contains(Pair("assertion", assertion)), + Contains(Pair("grant_type", kGrantParamUnescaped)))); + EXPECT_CALL(*mock, Post(_, expected_request, expected_form_data)) + .WillOnce([assertion, post_response]() { + auto response = std::make_unique(); + EXPECT_CALL(*response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*response), ExtractPayload) + .WillOnce( + Return(ByMove(MakeMockHttpPayloadSuccess(post_response)))); + + return std::unique_ptr( + std::move(response)); + }); + return mock; + }(); + + MockHttpClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call) + .WillOnce(Return(ByMove(std::move(token_client)))); + + auto const tp = std::chrono::system_clock::from_time_t(assertion_time); + ServiceAccountCredentials credentials(info, Options{}, + mock_client_factory.AsStdFunction()); + // Calls Refresh to obtain the access token for our authorization header. + auto token = credentials.GetToken(tp); + 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) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + EXPECT_FALSE(ServiceAccountUseOAuth(*info)); + + { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + EXPECT_TRUE(ServiceAccountUseOAuth(*info)); + } + + auto jwt_enabled_info = *info; + jwt_enabled_info.enable_self_signed_jwt = true; + EXPECT_FALSE(ServiceAccountUseOAuth(jwt_enabled_info)); + + auto p12_info = *info; + p12_info.private_key_id = "--unknown--"; + EXPECT_TRUE(ServiceAccountUseOAuth(p12_info)); + + auto ud_info = + ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); + ASSERT_STATUS_OK(ud_info); + EXPECT_FALSE(ServiceAccountUseOAuth(*ud_info)); + + { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + auto gdu_info = *ud_info; + gdu_info.universe_domain = GoogleDefaultUniverseDomain(); + EXPECT_TRUE(ServiceAccountUseOAuth(gdu_info)); + } +} + +TEST(ServiceAccountCredentialsTest, MakeSelfSignedJWT) { + auto info = + ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); + ASSERT_STATUS_OK(info); + auto const now = std::chrono::system_clock::now(); + auto actual = MakeSelfSignedJWT(*info, now); + ASSERT_STATUS_OK(actual); + + std::vector components = absl::StrSplit(*actual, '.'); + std::vector decoded(components.size()); + std::transform(components.begin(), components.end(), decoded.begin(), + [](std::string const& e) { + auto v = UrlsafeBase64Decode(e).value(); + return std::string{v.begin(), v.end()}; + }); + ASSERT_THAT(3, decoded.size()); + auto const header = nlohmann::json::parse(decoded[0], nullptr); + ASSERT_FALSE(header.is_null()) << "header=" << decoded[0]; + auto const payload = nlohmann::json::parse(decoded[1], nullptr); + ASSERT_FALSE(payload.is_null()) << "payload=" << decoded[1]; + + auto const expected_header = nlohmann::json{ + {"alg", "RS256"}, {"typ", "JWT"}, {"kid", info->private_key_id}}; + + auto const iat = + std::chrono::duration_cast(now.time_since_epoch()); + auto const exp = iat + std::chrono::hours(1); + auto const expected_payload = nlohmann::json{ + {"iss", info->client_email}, + {"sub", info->client_email}, + {"iat", iat.count()}, + {"exp", exp.count()}, + {"scope", "https://www.googleapis.com/auth/cloud-platform"}, + }; + + ASSERT_EQ(expected_header, header) << "header=" << header; + ASSERT_EQ(expected_payload, payload) << "payload=" << payload; + + auto signature = internal::SignUsingSha256( + components[0] + '.' + components[1], info->private_key); + ASSERT_STATUS_OK(signature); + EXPECT_THAT(*signature, + ElementsAreArray(decoded[2].begin(), decoded[2].end())); +} + +TEST(ServiceAccountCredentialsTest, MakeSelfSignedJWTWithScopes) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + info->scopes = std::set{"test-only-s1", "test-only-s2"}; + + auto const now = std::chrono::system_clock::now(); + auto actual = MakeSelfSignedJWT(*info, now); + ASSERT_STATUS_OK(actual); + + std::vector components = absl::StrSplit(*actual, '.'); + std::vector decoded(components.size()); + std::transform(components.begin(), components.end(), decoded.begin(), + [](std::string const& e) { + auto v = UrlsafeBase64Decode(e).value(); + return std::string{v.begin(), v.end()}; + }); + ASSERT_THAT(3, decoded.size()); + auto const header = nlohmann::json::parse(decoded[0], nullptr); + ASSERT_FALSE(header.is_null()) << "header=" << decoded[0]; + auto const payload = nlohmann::json::parse(decoded[1], nullptr); + ASSERT_FALSE(payload.is_null()) << "payload=" << decoded[1]; + + auto const expected_header = nlohmann::json{ + {"alg", "RS256"}, {"typ", "JWT"}, {"kid", info->private_key_id}}; + + auto const iat = + std::chrono::duration_cast(now.time_since_epoch()); + auto const exp = iat + std::chrono::hours(1); + auto const expected_payload = nlohmann::json{ + {"iss", info->client_email}, + {"sub", info->client_email}, + {"iat", iat.count()}, + {"exp", exp.count()}, + {"scope", "test-only-s1 test-only-s2"}, + }; + + ASSERT_EQ(expected_header, header) << "header=" << header; + ASSERT_EQ(expected_payload, payload) << "payload=" << payload; + + auto signature = internal::SignUsingSha256( + components[0] + '.' + components[1], info->private_key); + ASSERT_STATUS_OK(signature); + EXPECT_THAT(*signature, + ElementsAreArray(decoded[2].begin(), decoded[2].end())); +} + +/// @test Verify that we can create service account credentials from a keyfile. +TEST(ServiceAccountCredentialsTest, + RefreshingSendsCorrectRequestBodyAndParsesResponse) { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + EXPECT_EQ(info->client_email, kClientEmail); + EXPECT_EQ(info->private_key_id, kPrivateKeyId); + EXPECT_EQ(info->private_key, kPrivateKey); + EXPECT_EQ(info->token_uri, kTokenUri); + + auto const expected_header = + nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + + auto const iat = static_cast(kFixedJwtTimestamp); + auto const exp = iat + 3600; + auto const expected_payload = nlohmann::json{ + {"iss", kClientEmail}, + {"scope", "https://www.googleapis.com/auth/cloud-platform"}, + {"aud", kTokenUri}, + {"iat", iat}, + {"exp", exp}, + }; + + auto const assertion = MakeJWTAssertion(expected_header.dump(), + expected_payload.dump(), kPrivateKey); + CheckInfoYieldsExpectedAssertion(*info, assertion, kFixedJwtTimestamp); +} + +/// @test Verify that ServiceAccountCredentials defaults to self-signed JWTs. +TEST(ServiceAccountCredentialsTest, RefreshWithSelfSignedJWT) { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", absl::nullopt); + + auto info = + ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); + ASSERT_STATUS_OK(info); + + auto const expected_header = + nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + + std::string response = R"""({ + "token_type": "Type", + "access_token": "access-token-value", + "expires_in": 1234 + })"""; + + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + auto const now = std::chrono::system_clock::now(); + auto access_token = credentials.GetToken(now); + ASSERT_STATUS_OK(access_token); + + auto token = MakeSelfSignedJWT(*info, now); + ASSERT_STATUS_OK(token); + + EXPECT_EQ(access_token->token, *token); +} + +/// @test Verify that we can create service account credentials from a keyfile. +TEST(ServiceAccountCredentialsTest, + RefreshingSendsCorrectRequestBodyAndParsesResponseForNonDefaultVals) { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + info->scopes = {kScopeForTest0}; + info->subject = std::string(kSubjectForGrant); + + auto const expected_header = + nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + + auto const iat = static_cast(kFixedJwtTimestamp); + auto const exp = iat + 3600; + auto const expected_payload = nlohmann::json{ + {"iss", kClientEmail}, {"scope", kScopeForTest0}, + {"aud", kTokenUri}, {"iat", iat}, + {"exp", exp}, {"sub", kSubjectForGrant}, + }; + + auto const assertion = MakeJWTAssertion(expected_header.dump(), + expected_payload.dump(), kPrivateKey); + CheckInfoYieldsExpectedAssertion(*info, assertion, kFixedJwtTimestamp); +} + +TEST(ServiceAccountCredentialsTest, MultipleScopes) { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + auto expected_info = *info; + // .scopes is a `std::set` so we need to preserve order. + ASSERT_LT(std::string{kScopeForTest1}, kScopeForTest0); + expected_info.scopes = {std::string{kScopeForTest1} + " " + kScopeForTest0}; + expected_info.subject = std::string(kSubjectForGrant); + auto const now = std::chrono::system_clock::now(); + auto const expected_components = + AssertionComponentsFromInfo(expected_info, now); + + auto actual_info = *info; + actual_info.scopes = {kScopeForTest0, kScopeForTest1}; + actual_info.subject = std::string(kSubjectForGrant); + auto const actual_components = AssertionComponentsFromInfo(actual_info, now); + EXPECT_EQ(actual_components, expected_components); +} + +/// @test Verify that `nlohmann::json::parse()` failures are reported as +/// is_discarded. +TEST(ServiceAccountCredentialsTest, ParseInvalidJson) { + std::string config = R"""( not-a-valid-json-string )"""; + // The documentation for `nlohmann::json::parse()` is a bit ambiguous, so + // wrote a little test to verify it works as I expected. + auto parsed = nlohmann::json::parse(config, nullptr, false); + EXPECT_TRUE(parsed.is_discarded()); + EXPECT_FALSE(parsed.is_null()); +} + +/// @test Verify that parsing a service account JSON string works. +TEST(ServiceAccountCredentialsTest, ParseSimple) { + std::string contents = R"""({ + "type": "service_account", + "private_key_id": "not-a-key-id-just-for-testing", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com", + "token_uri": "https://oauth2.googleapis.com/test_endpoint", + "universe_domain": "test-domain.net", + "project_id": "test-only-invalid-project-id" +})"""; + + auto actual = + ParseServiceAccountCredentials(contents, "test-data", "unused-uri"); + ASSERT_STATUS_OK(actual); + EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); + EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); + EXPECT_EQ("test-only@test-group.example.com", actual->client_email); + EXPECT_EQ("https://oauth2.googleapis.com/test_endpoint", actual->token_uri); + EXPECT_EQ("test-domain.net", actual->universe_domain); + EXPECT_EQ("test-only-invalid-project-id", actual->project_id); +} + +/// @test Verify that parsing a service account JSON string works. +TEST(ServiceAccountCredentialsTest, ParseUsesExplicitDefaultTokenUri) { + // No token_uri attribute here, so the default passed below should be used. + std::string contents = R"""({ + "type": "service_account", + "private_key_id": "not-a-key-id-just-for-testing", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com" +})"""; + + auto actual = ParseServiceAccountCredentials( + contents, "test-data", "https://oauth2.googleapis.com/test_endpoint"); + ASSERT_STATUS_OK(actual); + EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); + EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); + EXPECT_EQ("test-only@test-group.example.com", actual->client_email); + EXPECT_EQ("https://oauth2.googleapis.com/test_endpoint", actual->token_uri); +} + +/// @test Verify that parsing a service account JSON string works. +TEST(ServiceAccountCredentialsTest, ParseUsesImplicitDefaultTokenUri) { + // No token_uri attribute here. + std::string contents = R"""({ + "type": "service_account", + "private_key_id": "not-a-key-id-just-for-testing", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com" +})"""; + + // No token_uri passed in here, either. + auto actual = ParseServiceAccountCredentials(contents, "test-data"); + ASSERT_STATUS_OK(actual); + EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); + EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); + EXPECT_EQ("test-only@test-group.example.com", actual->client_email); + EXPECT_EQ(std::string(GoogleOAuthRefreshEndpoint()), actual->token_uri); +} + +TEST(ServiceAccountCredentialsTest, ParseUsesDefaultUniverseDomain) { + // No token_uri attribute here. + std::string contents = R"""({ + "type": "service_account", + "private_key_id": "not-a-key-id-just-for-testing", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com" +})"""; + + // No token_uri passed in here, either. + auto actual = ParseServiceAccountCredentials(contents, "test-data"); + ASSERT_STATUS_OK(actual); + EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); + EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); + EXPECT_EQ("test-only@test-group.example.com", actual->client_email); + EXPECT_EQ(GoogleDefaultUniverseDomain(), actual->universe_domain); +} + +TEST(ServiceAccountCredentialsTest, ParseMissingProjectId) { + std::string contents = R"""({ + "type": "service_account", + "private_key_id": "not-a-key-id-just-for-testing", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com", + "token_uri": "https://oauth2.googleapis.com/test_endpoint", + "universe_domain": "test-domain.net" +})"""; + + auto actual = + ParseServiceAccountCredentials(contents, "test-data", "unused-uri"); + ASSERT_STATUS_OK(actual); + EXPECT_EQ(actual->project_id, absl::nullopt); +} + +/// @test Verify that invalid contents result in a readable error. +TEST(ServiceAccountCredentialsTest, ParseInvalidContentsFails) { + std::string config = R"""( not-a-valid-json-string )"""; + + auto actual = ParseServiceAccountCredentials(config, "test-as-a-source"); + EXPECT_THAT(actual, + StatusIs(Not(StatusCode::kOk), + AllOf(HasSubstr("Invalid ServiceAccountCredentials"), + HasSubstr("test-as-a-source")))); +} + +/// @test Parsing a service account JSON string should detect empty fields. +TEST(ServiceAccountCredentialsTest, ParseEmptyFieldFails) { + std::string contents = R"""({ + "type": "service_account", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com", + "token_uri": "https://oauth2.googleapis.com/token" +})"""; + + for (auto const& field : {"private_key", "client_email", "token_uri", + "universe_domain", "project_id"}) { + auto json = nlohmann::json::parse(contents); + json[field] = ""; + auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + EXPECT_THAT(actual, + StatusIs(Not(StatusCode::kOk), + AllOf(HasSubstr(field), HasSubstr(" field is empty"), + HasSubstr("test-data")))); + } +} + +/// @test Parsing a service account JSON string should detect invalid fields. +TEST(ServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { + std::string contents = R"""({ + "type": "service_account", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com", + "token_uri": "https://oauth2.googleapis.com/token" +})"""; + + for (auto const& field : {"private_key", "private_key_id", "client_email", + "token_uri", "universe_domain", "project_id"}) { + auto json = nlohmann::json::parse(contents); + json[field] = true; + auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + EXPECT_THAT( + actual, + StatusIs(Not(StatusCode::kOk), + AllOf(HasSubstr(field), + HasSubstr(" field is present and is not a string"), + HasSubstr("test-data")))); + } +} + +/// @test Parsing a service account JSON string should detect missing fields. +TEST(ServiceAccountCredentialsTest, ParseMissingFieldFails) { + std::string contents = R"""({ + "type": "service_account", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com", + "token_uri": "https://oauth2.googleapis.com/token" +})"""; + + for (auto const& field : {"private_key", "client_email"}) { + auto json = nlohmann::json::parse(contents); + json.erase(field); + auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + EXPECT_THAT(actual, + StatusIs(Not(StatusCode::kOk), + AllOf(HasSubstr(field), HasSubstr(" field is missing"), + HasSubstr("test-data")))); + } +} + +/// @test Parsing a service account JSON string allows an optional field. +TEST(ServiceAccountCredentialsTest, ParseOptionalField) { + std::string contents = R"""({ + "type": "service_account", + "private_key_id": "", + "private_key": "not-a-valid-key-just-for-testing", + "client_email": "test-only@test-group.example.com", + "token_uri": "https://oauth2.googleapis.com/token" +})"""; + + auto json = nlohmann::json::parse(contents); + auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + ASSERT_STATUS_OK(actual.status()); +} + +/// @test Verify that we can create sign blobs using a service account. +TEST(ServiceAccountCredentialsTest, SignBlob) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + + std::string blob = R"""(GET +rmYdCNHKFXam78uCt7xQLw== +text/plain +1388534400 +x-goog-encryption-algorithm:AES256 +x-goog-meta-foo:bar,baz +/bucket/objectname)"""; + + auto actual = credentials.SignBlob(info->client_email, blob); + ASSERT_STATUS_OK(actual); + + // To generate the expected output I used: + // `openssl dgst -sha256 -sign private.pem blob.txt | openssl base64 -A` + // where `blob.txt` contains the `blob` string, and `private.pem` contains + // the private key embedded in `kJsonKeyfileContents`. + std::string expected_signed = + "Zsy8o5ci07DQTvO/" + "SVr47PKsCXvN+" + "FzXga0iYrReAnngdZYewHdcAnMQ8bZvFlTM8HY3msrRw64Jc6hoXVL979An5ugXoZ1ol/" + "DT1KlKp3l9E0JSIbqL88ogpElTxFvgPHOtHOUsy2mzhqOVrNSXSj4EM50gKHhvHKSbFq8Pcj" + "lAkROtq5gqp5t0OFd7EMIaRH+tekVUZjQPfFT/" + "hRW9bSCCV8w1Ex+" + "QxmB5z7P7zZn2pl7JAcL850emTo8f2tfv1xXWQGhACvIJeMdPmyjbc04Ye4M8Ljpkg3YhE6l" + "4GwC2MnI8TkuoHe4Bj2MvA8mM8TVwIvpBs6Etsj6Jdaz4rg=="; + internal::Base64Encoder encoder; + for (auto const& c : *actual) { + encoder.PushBack(c); + } + EXPECT_EQ(expected_signed, std::move(encoder).FlushAndPad()); +} + +/// @test Verify that signing blobs fails with invalid e-mail. +TEST(ServiceAccountCredentialsTest, SignBlobFailure) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + + auto actual = credentials.SignBlob("fake@fake.com", "test-blob"); + EXPECT_THAT( + actual, + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("The current_credentials cannot sign blobs for "))); +} + +TEST(ServiceAccountCredentialsTest, UniverseDomainAccessorDefaultGDU) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + auto actual = credentials.universe_domain(); + EXPECT_THAT(actual, IsOkAndHolds(GoogleDefaultUniverseDomain())); +} + +TEST(ServiceAccountCredentialsTest, UniverseDomainAccessorCustom) { + auto info = + ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + auto actual = credentials.universe_domain(); + EXPECT_THAT(actual, IsOkAndHolds(kUniverseDomain)); +} + +TEST(ServiceAccountCredentialsTest, UniverseDomainAccessorFailure) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + info->universe_domain = absl::nullopt; + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + auto actual = credentials.universe_domain(); + EXPECT_THAT( + actual, + StatusIs(StatusCode::kNotFound, + HasSubstr("universe_domain is not present in the credentials"))); +} + +TEST(ServiceAccountCredentialsTest, ProjectIdUndefined) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + info->project_id.reset(); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + EXPECT_THAT(credentials.project_id(), + StatusIs(StatusCode::kNotFound, HasSubstr("project_id"))); + EXPECT_THAT(credentials.project_id({}), + StatusIs(StatusCode::kNotFound, HasSubstr("project_id"))); +} + +TEST(ServiceAccountCredentialsTest, ProjectIdDefined) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + EXPECT_THAT(credentials.project_id(), IsOkAndHolds("test-only-project-id")); + EXPECT_THAT(credentials.project_id({}), IsOkAndHolds("test-only-project-id")); +} + +/// @test Verify that we can get the client id from a service account. +TEST(ServiceAccountCredentialsTest, ClientId) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + MockHttpClientFactory mock_http_client_factory; + EXPECT_CALL(mock_http_client_factory, Call).Times(0); + ServiceAccountCredentials credentials( + *info, Options{}, mock_http_client_factory.AsStdFunction()); + + EXPECT_EQ(kClientEmail, credentials.AccountEmail()); + EXPECT_EQ(kPrivateKeyId, credentials.KeyId()); +} + +/// @test Verify we can obtain JWT assertion components given the info parsed +/// from a keyfile. +TEST(ServiceAccountCredentialsTest, AssertionComponentsFromInfo) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + auto const now = std::chrono::system_clock::now(); + auto components = AssertionComponentsFromInfo(*info, now); + + auto header = nlohmann::json::parse(components.first); + EXPECT_EQ("RS256", header.value("alg", "")); + EXPECT_EQ("JWT", header.value("typ", "")); + EXPECT_EQ(info->private_key_id, header.value("kid", "")); + + auto payload = nlohmann::json::parse(components.second); + EXPECT_EQ(std::chrono::system_clock::to_time_t(now), payload.value("iat", 0)); + EXPECT_EQ( + std::chrono::system_clock::to_time_t(now + std::chrono::seconds(3600)), + payload.value("exp", 0)); + EXPECT_EQ(info->client_email, payload.value("iss", "")); + EXPECT_EQ(info->token_uri, payload.value("aud", "")); +} + +/// @test Verify we can construct a JWT assertion given the info parsed from a +/// keyfile. +TEST(ServiceAccountCredentialsTest, MakeJWTAssertion) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + + auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); + auto components = AssertionComponentsFromInfo(*info, tp); + auto assertion = + MakeJWTAssertion(components.first, components.second, info->private_key); + + std::vector actual_tokens = absl::StrSplit(assertion, '.'); + ASSERT_EQ(actual_tokens.size(), 3); + std::vector> decoded(actual_tokens.size()); + std::transform( + actual_tokens.begin(), actual_tokens.end(), decoded.begin(), + [](std::string const& e) { return UrlsafeBase64Decode(e).value(); }); + + // Verify this is a valid key. + auto const signature = + SignUsingSha256(actual_tokens[0] + '.' + actual_tokens[1], kPrivateKey); + ASSERT_STATUS_OK(signature); + EXPECT_EQ(*signature, decoded[2]); + + // Verify the header and payloads are valid. + auto const header = + nlohmann::json::parse(decoded[0].begin(), decoded[0].end()); + auto const expected_header = + nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + EXPECT_EQ(header, expected_header); + + auto const payload = nlohmann::json::parse(decoded[1]); + auto const iat = static_cast(kFixedJwtTimestamp); + auto const exp = iat + 3600; + auto const expected_payload = nlohmann::json{ + {"iss", kClientEmail}, + {"scope", "https://www.googleapis.com/auth/cloud-platform"}, + {"aud", kTokenUri}, + {"iat", iat}, + {"exp", exp}, + }; + + EXPECT_EQ(payload, expected_payload); +} + +/// @test Verify we can construct a service account refresh payload given the +/// info parsed from a keyfile. +TEST(ServiceAccountCredentialsTest, CreateServiceAccountRefreshPayload) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + auto const now = std::chrono::system_clock::now(); + auto components = AssertionComponentsFromInfo(*info, now); + auto assertion = + MakeJWTAssertion(components.first, components.second, info->private_key); + auto actual_payload = CreateServiceAccountRefreshPayload(*info, now); + + EXPECT_THAT(actual_payload, Contains(std::pair( + "assertion", assertion))); + EXPECT_THAT(actual_payload, Contains(std::pair( + "grant_type", kGrantParamUnescaped))); +} + +/// @test Parsing a refresh response with missing fields results in failure. +TEST(ServiceAccountCredentialsTest, + ParseServiceAccountRefreshResponseMissingFields) { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + + std::string r1 = R"""({})"""; + // Does not have access_token. + std::string r2 = R"""({ + "token_type": "Type", + "id_token": "id-token-value", + "expires_in": 1000 +})"""; + + auto mock_response1 = std::make_unique(); + EXPECT_CALL(*mock_response1, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kBadRequest)); + EXPECT_CALL(std::move(*mock_response1), ExtractPayload) + .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r1)))); + + auto mock_response2 = std::make_unique(); + EXPECT_CALL(*mock_response2, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kBadRequest)); + EXPECT_CALL(std::move(*mock_response2), ExtractPayload) + .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r2)))); + + auto const now = std::chrono::system_clock::now(); + auto status = ParseServiceAccountRefreshResponse(*mock_response1, now); + EXPECT_THAT(status, + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("Could not find all required fields"))); + + status = ParseServiceAccountRefreshResponse(*mock_response2, now); + EXPECT_THAT(status, + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("Could not find all required fields"))); +} + +/// @test Parsing a refresh response yields an access token. +TEST(ServiceAccountCredentialsTest, ParseServiceAccountRefreshResponse) { + ScopedEnvironment disable_self_signed_jwt( + "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); + + auto const expires_in = std::chrono::seconds(1000); + std::string r1 = R"""({ + "token_type": "Type", + "access_token": "access-token-r1", + "expires_in": 1000 +})"""; + + auto mock_response1 = std::make_unique(); + EXPECT_CALL(*mock_response1, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*mock_response1), ExtractPayload) + .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r1)))); + + auto const now = std::chrono::system_clock::now(); + auto status = ParseServiceAccountRefreshResponse(*mock_response1, now); + EXPECT_STATUS_OK(status); + auto token = *status; + EXPECT_EQ(token.expiration, now + expires_in); + EXPECT_EQ(token.token, "access-token-r1"); +} + +TEST(ServiceAccountCredentialsTest, + ApplyServiceAccountCredentialsInfoOverrides) { + auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + + auto options = Options{} + .set(std::vector( + {"https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/cloud-platform"})) + .set("my-subject"); + + ApplyServiceAccountCredentialsInfoOverrides(options, *info); + + auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); + auto components = AssertionComponentsFromInfo(*info, tp); + auto assertion = + MakeJWTAssertion(components.first, components.second, info->private_key); + + std::vector actual_tokens = absl::StrSplit(assertion, '.'); + ASSERT_EQ(actual_tokens.size(), 3); + std::vector> decoded(actual_tokens.size()); + std::transform( + actual_tokens.begin(), actual_tokens.end(), decoded.begin(), + [](std::string const& e) { return UrlsafeBase64Decode(e).value(); }); + + // Verify this is a valid key. + auto const signature = + SignUsingSha256(actual_tokens[0] + '.' + actual_tokens[1], kPrivateKey); + ASSERT_STATUS_OK(signature); + EXPECT_EQ(*signature, decoded[2]); + + // Verify the header and payloads are valid. + auto const header = + nlohmann::json::parse(decoded[0].begin(), decoded[0].end()); + auto const expected_header = + nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + EXPECT_EQ(header, expected_header); + + auto const payload = nlohmann::json::parse(decoded[1]); + auto const iat = static_cast(kFixedJwtTimestamp); + auto const exp = iat + 3600; + auto const expected_payload = nlohmann::json{ + {"iss", kClientEmail}, + {"scope", + "https://www.googleapis.com/auth/cloud-platform " + "https://www.googleapis.com/auth/userinfo.email"}, + {"aud", kTokenUri}, + {"iat", iat}, + {"exp", exp}, + {"sub", "my-subject"}, + }; + + EXPECT_EQ(payload, expected_payload); +} +#endif +} // namespace +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace oauth2_internal +} // namespace cloud +} // namespace google diff --git a/google/cloud/internal/oauth2_google_credentials.cc b/google/cloud/internal/oauth2_google_credentials.cc index 2f51da36f4539..64a8079aff32a 100644 --- a/google/cloud/internal/oauth2_google_credentials.cc +++ b/google/cloud/internal/oauth2_google_credentials.cc @@ -20,6 +20,7 @@ #include "google/cloud/internal/oauth2_compute_engine_credentials.h" #include "google/cloud/internal/oauth2_credentials.h" #include "google/cloud/internal/oauth2_external_account_credentials.h" +#include "google/cloud/internal/oauth2_gdch_service_account_credentials.h" #include "google/cloud/internal/oauth2_google_application_default_credentials_file.h" #include "google/cloud/internal/oauth2_http_client_factory.h" #include "google/cloud/internal/oauth2_impersonate_service_account_credentials.h" @@ -98,6 +99,14 @@ StatusOr> LoadCredsFromString( std::make_unique( config, std::move(rest_stub))); } + if (cred_type == "gdch_service_account") { + auto info = ParseGDCHServiceAccountCredentials(contents, path); + if (!info) return std::move(info).status(); + return std::unique_ptr( + std::make_unique( + *info, options, std::move(client_factory))); + } + return internal::InvalidArgumentError( "Unsupported credential type (" + cred_type + ") when reading Application Default Credentials file " From 69c9f7dec64dd13d146a86cbd035f89696e50242 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Tue, 21 Apr 2026 15:54:40 -0400 Subject: [PATCH 2/9] oauth2 with UT --- ci/cloudbuild/builds/coverage.sh | 2 +- ...oauth2_gdch_service_account_credentials.cc | 42 +- .../oauth2_gdch_service_account_credentials.h | 63 +- ...2_gdch_service_account_credentials_test.cc | 815 ++++-------------- .../internal/oauth2_google_credentials.cc | 13 +- 5 files changed, 217 insertions(+), 718 deletions(-) diff --git a/ci/cloudbuild/builds/coverage.sh b/ci/cloudbuild/builds/coverage.sh index e60e3ab160307..a96431fc1be62 100755 --- a/ci/cloudbuild/builds/coverage.sh +++ b/ci/cloudbuild/builds/coverage.sh @@ -71,7 +71,7 @@ for arg in "${integration_args[@]}"; do esac i=$((++i)) done -integration::bazel_with_emulators coverage "${args[@]}" "${integration_args[@]}" +#integration::bazel_with_emulators coverage "${args[@]}" "${integration_args[@]}" # Where does this token come from? For triggered ci/pr builds GCB will securely # inject this into the environment. See the "secretEnv" setting in the diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc index 68c9663cfbde4..2a239a57aaf9d 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -181,23 +181,29 @@ StatusOr ParseGDCHServiceAccountRefreshResponse( auto access_token = nlohmann::json::parse(*payload, nullptr, false); if (access_token.is_discarded() || access_token.count("access_token") == 0 || access_token.count("expires_in") == 0 || - access_token.count("token_type") == 0) { + access_token.count("token_type") == 0 || + access_token.count("issued_token_type") == 0) { auto error_payload = *payload + "Could not find all required fields in response (access_token," - " expires_in, token_type) while trying to obtain an access token for" - " GDCH service account credentials."; + " expires_in, token_type, issued_token_type) while trying to obtain an" + " access token for GDCH service account credentials."; return AsStatus(status_code, error_payload); } - auto expires_in = std::chrono::seconds(access_token.value("expires_in", 0)); return AccessToken{access_token.value("access_token", ""), now + expires_in}; } -StatusOr> -CreateGDCHServiceAccountCredentialsFromJsonContents( - std::string const& contents, Options const& options, - HttpClientFactory client_factory) { +StatusOr> GDCHServiceAccountCredentials:: + CreateGDCHServiceAccountCredentialsFromJsonContents( + std::string const& contents, Options const& options, + HttpClientFactory client_factory) { + if (!options.has()) { + return internal::InvalidArgumentError( + "Creation of GDCH Service Account credentials requires the " + "AudienceOption to be set.", + GCP_ERROR_INFO()); + } auto info = ParseGDCHServiceAccountCredentials(contents, "memory"); if (!info) return info.status(); // Verify this is usable before returning it. @@ -206,13 +212,14 @@ CreateGDCHServiceAccountCredentialsFromJsonContents( auto jwt = MakeJWTAssertionNoThrow(components.first, components.second, info->private_key); if (!jwt) return jwt.status(); - return StatusOr>( - std::make_shared( - *info, options, std::move(client_factory))); + return StatusOr>( + std::unique_ptr( + new GDCHServiceAccountCredentials(*info, options, + std::move(client_factory)))); } -StatusOr> -CreateGDCHServiceAccountCredentialsFromJsonFilePath( +StatusOr> +GDCHServiceAccountCredentials::CreateGDCHServiceAccountCredentialsFromFilePath( std::string const& path, Options const& options, HttpClientFactory client_factory) { std::ifstream is(path); @@ -227,15 +234,6 @@ CreateGDCHServiceAccountCredentialsFromJsonFilePath( std::move(contents), options, std::move(client_factory)); } -StatusOr> -CreateGDCHServiceAccountCredentialsFromFilePath( - std::string const& path, Options const& options, - HttpClientFactory client_factory) { - auto credentials = CreateGDCHServiceAccountCredentialsFromJsonFilePath( - path, options, client_factory); - return credentials; -} - GDCHServiceAccountCredentials::GDCHServiceAccountCredentials( GDCHServiceAccountCredentialsInfo info, Options options, HttpClientFactory client_factory) diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.h b/google/cloud/internal/oauth2_gdch_service_account_credentials.h index ab82bcb78ffba..2ba3f9bd4a5c4 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.h +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -80,32 +80,6 @@ CreateGDCHServiceAccountRefreshPayload( GDCHServiceAccountCredentialsInfo const& info, std::chrono::system_clock::time_point now); -/** - * Creates a GDCHServiceAccountCredentials from a JSON string. - */ -StatusOr> -CreateGDCHServiceAccountCredentialsFromJsonContents( - std::string const& contents, Options const& options, - HttpClientFactory client_factory); - -/** - * Creates a GDCHServiceAccountCredentials from a JSON file at the specified - * path. - */ -StatusOr> -CreateGDCHServiceAccountCredentialsFromJsonFilePath( - std::string const& path, Options const& options, - HttpClientFactory client_factory); - -/** - * Creates a GDCHServiceAccountCredentials from a file at the specified path. - */ - -StatusOr> -CreateGDCHServiceAccountCredentialsFromFilePath( - std::string const& path, Options const& options, - HttpClientFactory client_factory); - /** * Implements GDCH service account credentials for REST clients. * @@ -170,17 +144,21 @@ CreateGDCHServiceAccountCredentialsFromFilePath( class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { public: /** - * Creates an instance of GDCHServiceAccountCredentials. - * - * @param rest_client a dependency injection point. It makes it possible to - * mock internal REST types. This should generally not be overridden - * except for testing. - * @param current_time_fn a dependency injection point to fetch the current - * time. This should generally not be overridden except for testing. + * Creates a GDCHServiceAccountCredentials from a JSON string. */ - GDCHServiceAccountCredentials(GDCHServiceAccountCredentialsInfo info, - Options options, - HttpClientFactory client_factory); + static StatusOr> + CreateGDCHServiceAccountCredentialsFromJsonContents( + std::string const& contents, Options const& options, + HttpClientFactory client_factory); + + /** + * Creates a GDCHServiceAccountCredentials from a file at the specified path. + */ + + static StatusOr> + CreateGDCHServiceAccountCredentialsFromFilePath( + std::string const& path, Options const& options, + HttpClientFactory client_factory); /** * Returns a key value pair for an "Authorization" header. @@ -198,6 +176,19 @@ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { StatusOr project_id(Options const&) const override; private: + /** + * Creates an instance of GDCHServiceAccountCredentials. + * + * @param rest_client a dependency injection point. It makes it possible to + * mock internal REST types. This should generally not be overridden + * except for testing. + * @param current_time_fn a dependency injection point to fetch the current + * time. This should generally not be overridden except for testing. + */ + GDCHServiceAccountCredentials(GDCHServiceAccountCredentialsInfo info, + Options options, + HttpClientFactory client_factory); + GDCHServiceAccountCredentialsInfo info_; Options options_; HttpClientFactory client_factory_; diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 5606aab4b8d10..025975abb189f 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -15,7 +15,6 @@ #include "google/cloud/internal/oauth2_gdch_service_account_credentials.h" #include "google/cloud/credentials.h" #include "google/cloud/internal/base64_transforms.h" -#include "google/cloud/internal/oauth2_credential_constants.h" #include "google/cloud/internal/oauth2_universe_domain.h" #include "google/cloud/internal/random.h" #include "google/cloud/internal/sign_using_sha256.h" @@ -23,8 +22,8 @@ #include "google/cloud/testing_util/mock_http_payload.h" #include "google/cloud/testing_util/mock_rest_client.h" #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 "absl/strings/str_split.h" #include #include @@ -35,7 +34,7 @@ namespace cloud { namespace oauth2_internal { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace { -#if 0 + using ::google::cloud::internal::SignUsingSha256; using ::google::cloud::internal::UrlsafeBase64Decode; using ::google::cloud::rest_internal::RestRequest; @@ -43,35 +42,26 @@ using ::google::cloud::testing_util::IsOkAndHolds; using ::google::cloud::testing_util::MakeMockHttpPayloadSuccess; using ::google::cloud::testing_util::MockRestClient; using ::google::cloud::testing_util::MockRestResponse; -using ::google::cloud::testing_util::ScopedEnvironment; using ::google::cloud::testing_util::StatusIs; using ::testing::_; using ::testing::AllOf; using ::testing::ByMove; using ::testing::Contains; -using ::testing::ElementsAreArray; +using ::testing::Eq; using ::testing::HasSubstr; using ::testing::MatcherCast; using ::testing::Not; using ::testing::Pair; using ::testing::Property; using ::testing::Return; -using ::testing::VariantWith; using MockHttpClientFactory = ::testing::MockFunction( Options const&)>; -constexpr char kScopeForTest0[] = - "https://www.googleapis.com/auth/devstorage.full_control"; -constexpr char kScopeForTest1[] = - "https://www.googleapis.com/auth/cloud-platform"; -constexpr std::time_t kFixedJwtTimestamp = 1530060324; -constexpr char kGrantParamUnescaped[] = - "urn:ietf:params:oauth:grant-type:jwt-bearer"; -constexpr char kSubjectForGrant[] = "user@foo.bar"; - -auto constexpr kProjectId = "test-only-project-id"; +auto constexpr kFixedJwtTimestamp = 1530060324; +auto constexpr kAudience = "test-audience"; +auto constexpr kProjectId = "test-project-id"; auto constexpr kPrivateKeyId = "a1a111aa1111a11a11a11aa111a111a1a1111111"; // This is an invalidated private key. It was created using the Google Cloud // Platform console, but then the key (and service account) were deleted. @@ -104,70 +94,64 @@ YvLLIc82V7eqcVJTZtaFkuht68qu/Jn1ezbzJMJ4YXDYo1+KFi+2CAGR06QILb+I lUtj+/nH3HDQjM4ltYfTPUg= -----END PRIVATE KEY----- )"""; -auto constexpr kClientEmail = - "test-only-email@test-only-project-id.iam.gserviceaccount.com"; -auto constexpr kClientId = "100000000000000000001"; -auto constexpr kAuthUri = "https://accounts.google.com/o/oauth2/auth"; -auto constexpr kTokenUri = "https://oauth2.googleapis.com/token"; -auto constexpr kAuthProviderX509CerlUrl = - "https://www.googleapis.com/oauth2/v1/certs"; -auto constexpr kClientX509CertUrl = - "https://www.googleapis.com/robot/v1/metadata/x509/" - "foo-email%40foo-project.iam.gserviceaccount.com"; +auto constexpr kServiceIdentityName = "test-service-identity"; +auto constexpr kCaCertPath = "/test/ca.crt"; +auto constexpr kTokenUri = "https://gdc.token.uri/v1/token"; nlohmann::json TestContents() { return nlohmann::json{ - {"type", "service_account"}, - {"project_id", kProjectId}, - {"private_key_id", kPrivateKeyId}, - {"private_key", kPrivateKey}, - {"client_email", kClientEmail}, - {"client_id", kClientId}, - {"auth_uri", kAuthUri}, - {"token_uri", kTokenUri}, - {"auth_provider_x509_cert_url", kAuthProviderX509CerlUrl}, - {"client_x509_cert_url", kClientX509CertUrl}, + {"project_id", kProjectId}, {"private_key_id", kPrivateKeyId}, + {"private_key", kPrivateKey}, {"name", kServiceIdentityName}, + {"ca_cert_path", kCaCertPath}, {"token_uri", kTokenUri}, }; } std::string MakeTestContents() { return TestContents().dump(); } -auto constexpr kUniverseDomain = "test-domain.net"; -std::string MakeUniverseDomainTestContents() { - auto json = TestContents(); - json["universe_domain"] = kUniverseDomain; - 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) { +/// @test Verify that we can create service account credentials from a keyfile. +TEST(GDCHServiceAccountCredentialsTest, + RefreshingSendsCorrectRequestBodyAndParsesResponse) { auto const post_response = std::string{R"""({ - "token_type": "Type", - "access_token": "access-token-value", - "expires_in": 1234 + "access_token":"access-token-value", + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3599 })"""}; + auto const expected_header = + nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + auto const iat = static_cast(kFixedJwtTimestamp); + auto const exp = iat + 3600; + auto const iss_sub_value = absl::StrCat("system:serviceaccount:", kProjectId, + ":", kServiceIdentityName); + auto const expected_payload = nlohmann::json{ + {"iss", iss_sub_value}, {"sub", iss_sub_value}, {"aud", kTokenUri}, + {"iat", iat}, {"exp", exp}, + }; + auto const assertion = MakeGDCHJWTAssertion( + expected_header.dump(), expected_payload.dump(), kPrivateKey); + auto token_client = [=] { using FormDataType = std::vector>; auto mock = std::make_unique(); - auto expected_request = Property(&RestRequest::path, info.token_uri); - auto expected_form_data = MatcherCast( - AllOf(Contains(Pair("assertion", assertion)), - Contains(Pair("grant_type", kGrantParamUnescaped)))); + auto expected_request = Property(&RestRequest::path, kTokenUri); + auto expected_form_data = MatcherCast(AllOf( + Contains(Pair("grant_type", + "urn:ietf:params:oauth:token-type:token-exchange")), + Contains(Pair("audience", kAudience)), + Contains(Pair("requested_token_type", + "urn:ietf:params:oauth:token-type:access_token")), + Contains(Pair("subject_token", assertion)), + Contains(Pair("subject_token_type", + "urn:k8s:params:oauth:token-type:serviceaccount")))); EXPECT_CALL(*mock, Post(_, expected_request, expected_form_data)) - .WillOnce([assertion, post_response]() { + .WillOnce([post_response]() { auto response = std::make_unique(); EXPECT_CALL(*response, StatusCode) .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); EXPECT_CALL(std::move(*response), ExtractPayload) .WillOnce( Return(ByMove(MakeMockHttpPayloadSuccess(post_response)))); - return std::unique_ptr( std::move(response)); }); @@ -178,264 +162,25 @@ void CheckInfoYieldsExpectedAssertion(ServiceAccountCredentialsInfo const& info, EXPECT_CALL(mock_client_factory, Call) .WillOnce(Return(ByMove(std::move(token_client)))); - auto const tp = std::chrono::system_clock::from_time_t(assertion_time); - ServiceAccountCredentials credentials(info, Options{}, - mock_client_factory.AsStdFunction()); + auto credentials = GDCHServiceAccountCredentials:: + CreateGDCHServiceAccountCredentialsFromJsonContents( + MakeTestContents(), Options{}.set(kAudience), + mock_client_factory.AsStdFunction()); + ASSERT_STATUS_OK(credentials); + auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); // Calls Refresh to obtain the access token for our authorization header. - auto token = credentials.GetToken(tp); + auto token = (*credentials)->GetToken(tp); 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) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - EXPECT_FALSE(ServiceAccountUseOAuth(*info)); - - { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - EXPECT_TRUE(ServiceAccountUseOAuth(*info)); - } - - auto jwt_enabled_info = *info; - jwt_enabled_info.enable_self_signed_jwt = true; - EXPECT_FALSE(ServiceAccountUseOAuth(jwt_enabled_info)); - - auto p12_info = *info; - p12_info.private_key_id = "--unknown--"; - EXPECT_TRUE(ServiceAccountUseOAuth(p12_info)); - - auto ud_info = - ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); - ASSERT_STATUS_OK(ud_info); - EXPECT_FALSE(ServiceAccountUseOAuth(*ud_info)); - - { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - auto gdu_info = *ud_info; - gdu_info.universe_domain = GoogleDefaultUniverseDomain(); - EXPECT_TRUE(ServiceAccountUseOAuth(gdu_info)); - } -} - -TEST(ServiceAccountCredentialsTest, MakeSelfSignedJWT) { - auto info = - ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); - ASSERT_STATUS_OK(info); - auto const now = std::chrono::system_clock::now(); - auto actual = MakeSelfSignedJWT(*info, now); - ASSERT_STATUS_OK(actual); - - std::vector components = absl::StrSplit(*actual, '.'); - std::vector decoded(components.size()); - std::transform(components.begin(), components.end(), decoded.begin(), - [](std::string const& e) { - auto v = UrlsafeBase64Decode(e).value(); - return std::string{v.begin(), v.end()}; - }); - ASSERT_THAT(3, decoded.size()); - auto const header = nlohmann::json::parse(decoded[0], nullptr); - ASSERT_FALSE(header.is_null()) << "header=" << decoded[0]; - auto const payload = nlohmann::json::parse(decoded[1], nullptr); - ASSERT_FALSE(payload.is_null()) << "payload=" << decoded[1]; - - auto const expected_header = nlohmann::json{ - {"alg", "RS256"}, {"typ", "JWT"}, {"kid", info->private_key_id}}; - - auto const iat = - std::chrono::duration_cast(now.time_since_epoch()); - auto const exp = iat + std::chrono::hours(1); - auto const expected_payload = nlohmann::json{ - {"iss", info->client_email}, - {"sub", info->client_email}, - {"iat", iat.count()}, - {"exp", exp.count()}, - {"scope", "https://www.googleapis.com/auth/cloud-platform"}, - }; - - ASSERT_EQ(expected_header, header) << "header=" << header; - ASSERT_EQ(expected_payload, payload) << "payload=" << payload; - - auto signature = internal::SignUsingSha256( - components[0] + '.' + components[1], info->private_key); - ASSERT_STATUS_OK(signature); - EXPECT_THAT(*signature, - ElementsAreArray(decoded[2].begin(), decoded[2].end())); -} - -TEST(ServiceAccountCredentialsTest, MakeSelfSignedJWTWithScopes) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - info->scopes = std::set{"test-only-s1", "test-only-s2"}; - - auto const now = std::chrono::system_clock::now(); - auto actual = MakeSelfSignedJWT(*info, now); - ASSERT_STATUS_OK(actual); - - std::vector components = absl::StrSplit(*actual, '.'); - std::vector decoded(components.size()); - std::transform(components.begin(), components.end(), decoded.begin(), - [](std::string const& e) { - auto v = UrlsafeBase64Decode(e).value(); - return std::string{v.begin(), v.end()}; - }); - ASSERT_THAT(3, decoded.size()); - auto const header = nlohmann::json::parse(decoded[0], nullptr); - ASSERT_FALSE(header.is_null()) << "header=" << decoded[0]; - auto const payload = nlohmann::json::parse(decoded[1], nullptr); - ASSERT_FALSE(payload.is_null()) << "payload=" << decoded[1]; - - auto const expected_header = nlohmann::json{ - {"alg", "RS256"}, {"typ", "JWT"}, {"kid", info->private_key_id}}; - - auto const iat = - std::chrono::duration_cast(now.time_since_epoch()); - auto const exp = iat + std::chrono::hours(1); - auto const expected_payload = nlohmann::json{ - {"iss", info->client_email}, - {"sub", info->client_email}, - {"iat", iat.count()}, - {"exp", exp.count()}, - {"scope", "test-only-s1 test-only-s2"}, - }; - - ASSERT_EQ(expected_header, header) << "header=" << header; - ASSERT_EQ(expected_payload, payload) << "payload=" << payload; - - auto signature = internal::SignUsingSha256( - components[0] + '.' + components[1], info->private_key); - ASSERT_STATUS_OK(signature); - EXPECT_THAT(*signature, - ElementsAreArray(decoded[2].begin(), decoded[2].end())); -} - -/// @test Verify that we can create service account credentials from a keyfile. -TEST(ServiceAccountCredentialsTest, - RefreshingSendsCorrectRequestBodyAndParsesResponse) { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - EXPECT_EQ(info->client_email, kClientEmail); - EXPECT_EQ(info->private_key_id, kPrivateKeyId); - EXPECT_EQ(info->private_key, kPrivateKey); - EXPECT_EQ(info->token_uri, kTokenUri); - - auto const expected_header = - nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; - - auto const iat = static_cast(kFixedJwtTimestamp); - auto const exp = iat + 3600; - auto const expected_payload = nlohmann::json{ - {"iss", kClientEmail}, - {"scope", "https://www.googleapis.com/auth/cloud-platform"}, - {"aud", kTokenUri}, - {"iat", iat}, - {"exp", exp}, - }; - - auto const assertion = MakeJWTAssertion(expected_header.dump(), - expected_payload.dump(), kPrivateKey); - CheckInfoYieldsExpectedAssertion(*info, assertion, kFixedJwtTimestamp); -} - -/// @test Verify that ServiceAccountCredentials defaults to self-signed JWTs. -TEST(ServiceAccountCredentialsTest, RefreshWithSelfSignedJWT) { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", absl::nullopt); - - auto info = - ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); - ASSERT_STATUS_OK(info); - - auto const expected_header = - nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; - - std::string response = R"""({ - "token_type": "Type", - "access_token": "access-token-value", - "expires_in": 1234 - })"""; - - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - auto const now = std::chrono::system_clock::now(); - auto access_token = credentials.GetToken(now); - ASSERT_STATUS_OK(access_token); - - auto token = MakeSelfSignedJWT(*info, now); - ASSERT_STATUS_OK(token); - - EXPECT_EQ(access_token->token, *token); -} - -/// @test Verify that we can create service account credentials from a keyfile. -TEST(ServiceAccountCredentialsTest, - RefreshingSendsCorrectRequestBodyAndParsesResponseForNonDefaultVals) { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - info->scopes = {kScopeForTest0}; - info->subject = std::string(kSubjectForGrant); - - auto const expected_header = - nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; - - auto const iat = static_cast(kFixedJwtTimestamp); - auto const exp = iat + 3600; - auto const expected_payload = nlohmann::json{ - {"iss", kClientEmail}, {"scope", kScopeForTest0}, - {"aud", kTokenUri}, {"iat", iat}, - {"exp", exp}, {"sub", kSubjectForGrant}, - }; - - auto const assertion = MakeJWTAssertion(expected_header.dump(), - expected_payload.dump(), kPrivateKey); - CheckInfoYieldsExpectedAssertion(*info, assertion, kFixedJwtTimestamp); -} - -TEST(ServiceAccountCredentialsTest, MultipleScopes) { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - auto expected_info = *info; - // .scopes is a `std::set` so we need to preserve order. - ASSERT_LT(std::string{kScopeForTest1}, kScopeForTest0); - expected_info.scopes = {std::string{kScopeForTest1} + " " + kScopeForTest0}; - expected_info.subject = std::string(kSubjectForGrant); - auto const now = std::chrono::system_clock::now(); - auto const expected_components = - AssertionComponentsFromInfo(expected_info, now); - - auto actual_info = *info; - actual_info.scopes = {kScopeForTest0, kScopeForTest1}; - actual_info.subject = std::string(kSubjectForGrant); - auto const actual_components = AssertionComponentsFromInfo(actual_info, now); - EXPECT_EQ(actual_components, expected_components); + EXPECT_EQ(token->expiration, tp + std::chrono::seconds(3599)); + + EXPECT_THAT((*credentials)->AccountEmail(), Eq(kServiceIdentityName)); + EXPECT_THAT((*credentials)->KeyId(), Eq(kPrivateKeyId)); } /// @test Verify that `nlohmann::json::parse()` failures are reported as /// is_discarded. -TEST(ServiceAccountCredentialsTest, ParseInvalidJson) { +TEST(GDCHServiceAccountCredentialsTest, ParseInvalidJson) { std::string config = R"""( not-a-valid-json-string )"""; // The documentation for `nlohmann::json::parse()` is a bit ambiguous, so // wrote a little test to verify it works as I expected. @@ -445,125 +190,53 @@ TEST(ServiceAccountCredentialsTest, ParseInvalidJson) { } /// @test Verify that parsing a service account JSON string works. -TEST(ServiceAccountCredentialsTest, ParseSimple) { - std::string contents = R"""({ - "type": "service_account", - "private_key_id": "not-a-key-id-just-for-testing", - "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com", - "token_uri": "https://oauth2.googleapis.com/test_endpoint", - "universe_domain": "test-domain.net", - "project_id": "test-only-invalid-project-id" -})"""; - - auto actual = - ParseServiceAccountCredentials(contents, "test-data", "unused-uri"); - ASSERT_STATUS_OK(actual); - EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); - EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); - EXPECT_EQ("test-only@test-group.example.com", actual->client_email); - EXPECT_EQ("https://oauth2.googleapis.com/test_endpoint", actual->token_uri); - EXPECT_EQ("test-domain.net", actual->universe_domain); - EXPECT_EQ("test-only-invalid-project-id", actual->project_id); -} - -/// @test Verify that parsing a service account JSON string works. -TEST(ServiceAccountCredentialsTest, ParseUsesExplicitDefaultTokenUri) { - // No token_uri attribute here, so the default passed below should be used. - std::string contents = R"""({ - "type": "service_account", - "private_key_id": "not-a-key-id-just-for-testing", - "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com" -})"""; - - auto actual = ParseServiceAccountCredentials( - contents, "test-data", "https://oauth2.googleapis.com/test_endpoint"); - ASSERT_STATUS_OK(actual); - EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); - EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); - EXPECT_EQ("test-only@test-group.example.com", actual->client_email); - EXPECT_EQ("https://oauth2.googleapis.com/test_endpoint", actual->token_uri); -} - -/// @test Verify that parsing a service account JSON string works. -TEST(ServiceAccountCredentialsTest, ParseUsesImplicitDefaultTokenUri) { - // No token_uri attribute here. - std::string contents = R"""({ - "type": "service_account", - "private_key_id": "not-a-key-id-just-for-testing", - "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com" -})"""; - - // No token_uri passed in here, either. - auto actual = ParseServiceAccountCredentials(contents, "test-data"); - ASSERT_STATUS_OK(actual); - EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); - EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); - EXPECT_EQ("test-only@test-group.example.com", actual->client_email); - EXPECT_EQ(std::string(GoogleOAuthRefreshEndpoint()), actual->token_uri); -} - -TEST(ServiceAccountCredentialsTest, ParseUsesDefaultUniverseDomain) { - // No token_uri attribute here. +TEST(GDCHServiceAccountCredentialsTest, ParseSimple) { std::string contents = R"""({ - "type": "service_account", + "project_id": "test-project-id", "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com" + "name": "test-service-identity", + "ca_cert_path": "/test/ca.crt", + "token_uri": "https://gdc.token.uri/v1/token" })"""; - // No token_uri passed in here, either. - auto actual = ParseServiceAccountCredentials(contents, "test-data"); + auto actual = ParseGDCHServiceAccountCredentials(contents, "test-data"); ASSERT_STATUS_OK(actual); + EXPECT_EQ("test-project-id", actual->project_id); EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); - EXPECT_EQ("test-only@test-group.example.com", actual->client_email); - EXPECT_EQ(GoogleDefaultUniverseDomain(), actual->universe_domain); -} - -TEST(ServiceAccountCredentialsTest, ParseMissingProjectId) { - std::string contents = R"""({ - "type": "service_account", - "private_key_id": "not-a-key-id-just-for-testing", - "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com", - "token_uri": "https://oauth2.googleapis.com/test_endpoint", - "universe_domain": "test-domain.net" -})"""; - - auto actual = - ParseServiceAccountCredentials(contents, "test-data", "unused-uri"); - ASSERT_STATUS_OK(actual); - EXPECT_EQ(actual->project_id, absl::nullopt); + EXPECT_EQ("test-service-identity", actual->service_identity_name); + EXPECT_EQ("/test/ca.crt", actual->ca_cert_path); + EXPECT_EQ("https://gdc.token.uri/v1/token", actual->token_uri); } /// @test Verify that invalid contents result in a readable error. -TEST(ServiceAccountCredentialsTest, ParseInvalidContentsFails) { +TEST(GDCHServiceAccountCredentialsTest, ParseInvalidContentsFails) { std::string config = R"""( not-a-valid-json-string )"""; - auto actual = ParseServiceAccountCredentials(config, "test-as-a-source"); + auto actual = ParseGDCHServiceAccountCredentials(config, "test-as-a-source"); EXPECT_THAT(actual, StatusIs(Not(StatusCode::kOk), - AllOf(HasSubstr("Invalid ServiceAccountCredentials"), + AllOf(HasSubstr("Invalid GDCHServiceAccountCredentials"), HasSubstr("test-as-a-source")))); } /// @test Parsing a service account JSON string should detect empty fields. -TEST(ServiceAccountCredentialsTest, ParseEmptyFieldFails) { +TEST(GDCHServiceAccountCredentialsTest, ParseEmptyFieldFails) { std::string contents = R"""({ - "type": "service_account", + "project_id": "test-project-id", + "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com", - "token_uri": "https://oauth2.googleapis.com/token" + "name": "test-service-identity", + "ca_cert_path": "/test/ca.crt", + "token_uri": "https://gdc.token.uri/v1/token" })"""; - for (auto const& field : {"private_key", "client_email", "token_uri", - "universe_domain", "project_id"}) { + for (auto const& field : {"project_id", "private_key_id", "private_key", + "name", "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json[field] = ""; - auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + auto actual = ParseGDCHServiceAccountCredentials(json.dump(), "test-data"); EXPECT_THAT(actual, StatusIs(Not(StatusCode::kOk), AllOf(HasSubstr(field), HasSubstr(" field is empty"), @@ -572,19 +245,21 @@ TEST(ServiceAccountCredentialsTest, ParseEmptyFieldFails) { } /// @test Parsing a service account JSON string should detect invalid fields. -TEST(ServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { +TEST(GDCHServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { std::string contents = R"""({ - "type": "service_account", + "project_id": "test-project-id", + "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com", - "token_uri": "https://oauth2.googleapis.com/token" + "name": "test-service-identity", + "ca_cert_path": "/test/ca.crt", + "token_uri": "https://gdc.token.uri/v1/token" })"""; - for (auto const& field : {"private_key", "private_key_id", "client_email", - "token_uri", "universe_domain", "project_id"}) { + for (auto const& field : {"project_id", "private_key_id", "private_key", + "name", "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json[field] = true; - auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + auto actual = ParseGDCHServiceAccountCredentials(json.dump(), "test-data"); EXPECT_THAT( actual, StatusIs(Not(StatusCode::kOk), @@ -595,18 +270,21 @@ TEST(ServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { } /// @test Parsing a service account JSON string should detect missing fields. -TEST(ServiceAccountCredentialsTest, ParseMissingFieldFails) { +TEST(GDCHServiceAccountCredentialsTest, ParseMissingFieldFails) { std::string contents = R"""({ - "type": "service_account", + "project_id": "test-project-id", + "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com", - "token_uri": "https://oauth2.googleapis.com/token" + "name": "test-service-identity", + "ca_cert_path": "/test/ca.crt", + "token_uri": "https://gdc.token.uri/v1/token" })"""; - for (auto const& field : {"private_key", "client_email"}) { + for (auto const& field : {"project_id", "private_key_id", "private_key", + "name", "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json.erase(field); - auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); + auto actual = ParseGDCHServiceAccountCredentials(json.dump(), "test-data"); EXPECT_THAT(actual, StatusIs(Not(StatusCode::kOk), AllOf(HasSubstr(field), HasSubstr(" field is missing"), @@ -614,162 +292,38 @@ TEST(ServiceAccountCredentialsTest, ParseMissingFieldFails) { } } -/// @test Parsing a service account JSON string allows an optional field. -TEST(ServiceAccountCredentialsTest, ParseOptionalField) { - std::string contents = R"""({ - "type": "service_account", - "private_key_id": "", - "private_key": "not-a-valid-key-just-for-testing", - "client_email": "test-only@test-group.example.com", - "token_uri": "https://oauth2.googleapis.com/token" -})"""; - - auto json = nlohmann::json::parse(contents); - auto actual = ParseServiceAccountCredentials(json.dump(), "test-data", ""); - ASSERT_STATUS_OK(actual.status()); -} - -/// @test Verify that we can create sign blobs using a service account. -TEST(ServiceAccountCredentialsTest, SignBlob) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - - std::string blob = R"""(GET -rmYdCNHKFXam78uCt7xQLw== -text/plain -1388534400 -x-goog-encryption-algorithm:AES256 -x-goog-meta-foo:bar,baz -/bucket/objectname)"""; - - auto actual = credentials.SignBlob(info->client_email, blob); - ASSERT_STATUS_OK(actual); - - // To generate the expected output I used: - // `openssl dgst -sha256 -sign private.pem blob.txt | openssl base64 -A` - // where `blob.txt` contains the `blob` string, and `private.pem` contains - // the private key embedded in `kJsonKeyfileContents`. - std::string expected_signed = - "Zsy8o5ci07DQTvO/" - "SVr47PKsCXvN+" - "FzXga0iYrReAnngdZYewHdcAnMQ8bZvFlTM8HY3msrRw64Jc6hoXVL979An5ugXoZ1ol/" - "DT1KlKp3l9E0JSIbqL88ogpElTxFvgPHOtHOUsy2mzhqOVrNSXSj4EM50gKHhvHKSbFq8Pcj" - "lAkROtq5gqp5t0OFd7EMIaRH+tekVUZjQPfFT/" - "hRW9bSCCV8w1Ex+" - "QxmB5z7P7zZn2pl7JAcL850emTo8f2tfv1xXWQGhACvIJeMdPmyjbc04Ye4M8Ljpkg3YhE6l" - "4GwC2MnI8TkuoHe4Bj2MvA8mM8TVwIvpBs6Etsj6Jdaz4rg=="; - internal::Base64Encoder encoder; - for (auto const& c : *actual) { - encoder.PushBack(c); - } - EXPECT_EQ(expected_signed, std::move(encoder).FlushAndPad()); -} - -/// @test Verify that signing blobs fails with invalid e-mail. -TEST(ServiceAccountCredentialsTest, SignBlobFailure) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); +TEST(GDCHServiceAccountCredentialsTest, ProjectIdDefined) { MockHttpClientFactory mock_http_client_factory; EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - - auto actual = credentials.SignBlob("fake@fake.com", "test-blob"); - EXPECT_THAT( - actual, - StatusIs(StatusCode::kInvalidArgument, - HasSubstr("The current_credentials cannot sign blobs for "))); -} -TEST(ServiceAccountCredentialsTest, UniverseDomainAccessorDefaultGDU) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - auto actual = credentials.universe_domain(); - EXPECT_THAT(actual, IsOkAndHolds(GoogleDefaultUniverseDomain())); + auto credentials = GDCHServiceAccountCredentials:: + CreateGDCHServiceAccountCredentialsFromJsonContents( + MakeTestContents(), Options{}.set(kAudience), + mock_http_client_factory.AsStdFunction()); + ASSERT_STATUS_OK(credentials); + EXPECT_THAT((*credentials)->project_id(), IsOkAndHolds(kProjectId)); + EXPECT_THAT((*credentials)->project_id({}), IsOkAndHolds(kProjectId)); } -TEST(ServiceAccountCredentialsTest, UniverseDomainAccessorCustom) { - auto info = - ParseServiceAccountCredentials(MakeUniverseDomainTestContents(), "test"); - ASSERT_STATUS_OK(info); +TEST(GDCHServiceAccountCredentialsTest, MissingAudienceOption) { MockHttpClientFactory mock_http_client_factory; EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - auto actual = credentials.universe_domain(); - EXPECT_THAT(actual, IsOkAndHolds(kUniverseDomain)); -} -TEST(ServiceAccountCredentialsTest, UniverseDomainAccessorFailure) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - info->universe_domain = absl::nullopt; - ASSERT_STATUS_OK(info); - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - auto actual = credentials.universe_domain(); - EXPECT_THAT( - actual, - StatusIs(StatusCode::kNotFound, - HasSubstr("universe_domain is not present in the credentials"))); -} - -TEST(ServiceAccountCredentialsTest, ProjectIdUndefined) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - info->project_id.reset(); - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - EXPECT_THAT(credentials.project_id(), - StatusIs(StatusCode::kNotFound, HasSubstr("project_id"))); - EXPECT_THAT(credentials.project_id({}), - StatusIs(StatusCode::kNotFound, HasSubstr("project_id"))); -} - -TEST(ServiceAccountCredentialsTest, ProjectIdDefined) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - EXPECT_THAT(credentials.project_id(), IsOkAndHolds("test-only-project-id")); - EXPECT_THAT(credentials.project_id({}), IsOkAndHolds("test-only-project-id")); -} - -/// @test Verify that we can get the client id from a service account. -TEST(ServiceAccountCredentialsTest, ClientId) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - MockHttpClientFactory mock_http_client_factory; - EXPECT_CALL(mock_http_client_factory, Call).Times(0); - ServiceAccountCredentials credentials( - *info, Options{}, mock_http_client_factory.AsStdFunction()); - - EXPECT_EQ(kClientEmail, credentials.AccountEmail()); - EXPECT_EQ(kPrivateKeyId, credentials.KeyId()); + auto credentials = GDCHServiceAccountCredentials:: + CreateGDCHServiceAccountCredentialsFromJsonContents( + MakeTestContents(), Options{}, + mock_http_client_factory.AsStdFunction()); + EXPECT_THAT(credentials, StatusIs(StatusCode::kInvalidArgument, + HasSubstr("requires the AudienceOption to be set"))); } /// @test Verify we can obtain JWT assertion components given the info parsed /// from a keyfile. -TEST(ServiceAccountCredentialsTest, AssertionComponentsFromInfo) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); +TEST(GDCHServiceAccountCredentialsTest, AssertionComponentsFromInfo) { + auto info = ParseGDCHServiceAccountCredentials(MakeTestContents(), "test"); ASSERT_STATUS_OK(info); auto const now = std::chrono::system_clock::now(); - auto components = AssertionComponentsFromInfo(*info, now); + auto components = GDCHAssertionComponentsFromInfo(*info, now); auto header = nlohmann::json::parse(components.first); EXPECT_EQ("RS256", header.value("alg", "")); @@ -781,20 +335,23 @@ TEST(ServiceAccountCredentialsTest, AssertionComponentsFromInfo) { EXPECT_EQ( std::chrono::system_clock::to_time_t(now + std::chrono::seconds(3600)), payload.value("exp", 0)); - EXPECT_EQ(info->client_email, payload.value("iss", "")); + auto const iss_sub_value = absl::StrCat("system:serviceaccount:", kProjectId, + ":", kServiceIdentityName); + EXPECT_EQ(iss_sub_value, payload.value("iss", "")); + EXPECT_EQ(iss_sub_value, payload.value("sub", "")); EXPECT_EQ(info->token_uri, payload.value("aud", "")); } /// @test Verify we can construct a JWT assertion given the info parsed from a /// keyfile. -TEST(ServiceAccountCredentialsTest, MakeJWTAssertion) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); +TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { + auto info = ParseGDCHServiceAccountCredentials(MakeTestContents(), "test"); ASSERT_STATUS_OK(info); auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); - auto components = AssertionComponentsFromInfo(*info, tp); - auto assertion = - MakeJWTAssertion(components.first, components.second, info->private_key); + auto components = GDCHAssertionComponentsFromInfo(*info, tp); + auto assertion = MakeGDCHJWTAssertion(components.first, components.second, + info->private_key); std::vector actual_tokens = absl::StrSplit(assertion, '.'); ASSERT_EQ(actual_tokens.size(), 3); @@ -819,12 +376,11 @@ TEST(ServiceAccountCredentialsTest, MakeJWTAssertion) { auto const payload = nlohmann::json::parse(decoded[1]); auto const iat = static_cast(kFixedJwtTimestamp); auto const exp = iat + 3600; + auto const iss_sub_value = absl::StrCat("system:serviceaccount:", kProjectId, + ":", kServiceIdentityName); auto const expected_payload = nlohmann::json{ - {"iss", kClientEmail}, - {"scope", "https://www.googleapis.com/auth/cloud-platform"}, - {"aud", kTokenUri}, - {"iat", iat}, - {"exp", exp}, + {"iss", iss_sub_value}, {"sub", iss_sub_value}, {"aud", kTokenUri}, + {"iat", iat}, {"exp", exp}, }; EXPECT_EQ(payload, expected_payload); @@ -832,33 +388,40 @@ TEST(ServiceAccountCredentialsTest, MakeJWTAssertion) { /// @test Verify we can construct a service account refresh payload given the /// info parsed from a keyfile. -TEST(ServiceAccountCredentialsTest, CreateServiceAccountRefreshPayload) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); +TEST(GDCHServiceAccountCredentialsTest, + CreateGDCHServiceAccountRefreshPayload) { + auto info = ParseGDCHServiceAccountCredentials(MakeTestContents(), "test"); ASSERT_STATUS_OK(info); + info->audience = kAudience; auto const now = std::chrono::system_clock::now(); - auto components = AssertionComponentsFromInfo(*info, now); - auto assertion = - MakeJWTAssertion(components.first, components.second, info->private_key); - auto actual_payload = CreateServiceAccountRefreshPayload(*info, now); - - EXPECT_THAT(actual_payload, Contains(std::pair( - "assertion", assertion))); - EXPECT_THAT(actual_payload, Contains(std::pair( - "grant_type", kGrantParamUnescaped))); + auto components = GDCHAssertionComponentsFromInfo(*info, now); + auto assertion = MakeGDCHJWTAssertion(components.first, components.second, + info->private_key); + auto actual_payload = CreateGDCHServiceAccountRefreshPayload(*info, now); + + EXPECT_THAT( + actual_payload, + Contains(Pair("grant_type", + "urn:ietf:params:oauth:token-type:token-exchange"))); + EXPECT_THAT(actual_payload, Contains(Pair("audience", kAudience))); + EXPECT_THAT(actual_payload, + Contains(Pair("requested_token_type", + "urn:ietf:params:oauth:token-type:access_token"))); + EXPECT_THAT(actual_payload, Contains(Pair("subject_token", assertion))); + EXPECT_THAT(actual_payload, + Contains(Pair("subject_token_type", + "urn:k8s:params:oauth:token-type:serviceaccount"))); } /// @test Parsing a refresh response with missing fields results in failure. -TEST(ServiceAccountCredentialsTest, - ParseServiceAccountRefreshResponseMissingFields) { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - +TEST(GDCHServiceAccountCredentialsTest, + ParseGDCHServiceAccountRefreshResponseMissingFields) { std::string r1 = R"""({})"""; // Does not have access_token. std::string r2 = R"""({ - "token_type": "Type", - "id_token": "id-token-value", - "expires_in": 1000 + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3599 })"""; auto mock_response1 = std::make_unique(); @@ -874,27 +437,26 @@ TEST(ServiceAccountCredentialsTest, .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r2)))); auto const now = std::chrono::system_clock::now(); - auto status = ParseServiceAccountRefreshResponse(*mock_response1, now); + auto status = ParseGDCHServiceAccountRefreshResponse(*mock_response1, now); EXPECT_THAT(status, StatusIs(StatusCode::kInvalidArgument, HasSubstr("Could not find all required fields"))); - status = ParseServiceAccountRefreshResponse(*mock_response2, now); + status = ParseGDCHServiceAccountRefreshResponse(*mock_response2, now); EXPECT_THAT(status, StatusIs(StatusCode::kInvalidArgument, HasSubstr("Could not find all required fields"))); } /// @test Parsing a refresh response yields an access token. -TEST(ServiceAccountCredentialsTest, ParseServiceAccountRefreshResponse) { - ScopedEnvironment disable_self_signed_jwt( - "GOOGLE_CLOUD_CPP_EXPERIMENTAL_DISABLE_SELF_SIGNED_JWT", "1"); - +TEST(GDCHServiceAccountCredentialsTest, + ParseGDCHServiceAccountRefreshResponse) { auto const expires_in = std::chrono::seconds(1000); std::string r1 = R"""({ - "token_type": "Type", - "access_token": "access-token-r1", - "expires_in": 1000 + "access_token":"access-token-r1", + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":1000 })"""; auto mock_response1 = std::make_unique(); @@ -904,68 +466,13 @@ TEST(ServiceAccountCredentialsTest, ParseServiceAccountRefreshResponse) { .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r1)))); auto const now = std::chrono::system_clock::now(); - auto status = ParseServiceAccountRefreshResponse(*mock_response1, now); + auto status = ParseGDCHServiceAccountRefreshResponse(*mock_response1, now); EXPECT_STATUS_OK(status); auto token = *status; EXPECT_EQ(token.expiration, now + expires_in); EXPECT_EQ(token.token, "access-token-r1"); } -TEST(ServiceAccountCredentialsTest, - ApplyServiceAccountCredentialsInfoOverrides) { - auto info = ParseServiceAccountCredentials(MakeTestContents(), "test"); - ASSERT_STATUS_OK(info); - - auto options = Options{} - .set(std::vector( - {"https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/cloud-platform"})) - .set("my-subject"); - - ApplyServiceAccountCredentialsInfoOverrides(options, *info); - - auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); - auto components = AssertionComponentsFromInfo(*info, tp); - auto assertion = - MakeJWTAssertion(components.first, components.second, info->private_key); - - std::vector actual_tokens = absl::StrSplit(assertion, '.'); - ASSERT_EQ(actual_tokens.size(), 3); - std::vector> decoded(actual_tokens.size()); - std::transform( - actual_tokens.begin(), actual_tokens.end(), decoded.begin(), - [](std::string const& e) { return UrlsafeBase64Decode(e).value(); }); - - // Verify this is a valid key. - auto const signature = - SignUsingSha256(actual_tokens[0] + '.' + actual_tokens[1], kPrivateKey); - ASSERT_STATUS_OK(signature); - EXPECT_EQ(*signature, decoded[2]); - - // Verify the header and payloads are valid. - auto const header = - nlohmann::json::parse(decoded[0].begin(), decoded[0].end()); - auto const expected_header = - nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; - EXPECT_EQ(header, expected_header); - - auto const payload = nlohmann::json::parse(decoded[1]); - auto const iat = static_cast(kFixedJwtTimestamp); - auto const exp = iat + 3600; - auto const expected_payload = nlohmann::json{ - {"iss", kClientEmail}, - {"scope", - "https://www.googleapis.com/auth/cloud-platform " - "https://www.googleapis.com/auth/userinfo.email"}, - {"aud", kTokenUri}, - {"iat", iat}, - {"exp", exp}, - {"sub", "my-subject"}, - }; - - EXPECT_EQ(payload, expected_payload); -} -#endif } // namespace GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal diff --git a/google/cloud/internal/oauth2_google_credentials.cc b/google/cloud/internal/oauth2_google_credentials.cc index 64a8079aff32a..6dfcc99f8cb25 100644 --- a/google/cloud/internal/oauth2_google_credentials.cc +++ b/google/cloud/internal/oauth2_google_credentials.cc @@ -100,11 +100,14 @@ StatusOr> LoadCredsFromString( config, std::move(rest_stub))); } if (cred_type == "gdch_service_account") { - auto info = ParseGDCHServiceAccountCredentials(contents, path); - if (!info) return std::move(info).status(); - return std::unique_ptr( - std::make_unique( - *info, options, std::move(client_factory))); + return GDCHServiceAccountCredentials:: + CreateGDCHServiceAccountCredentialsFromJsonContents( + contents, options, std::move(client_factory)); + // auto info = ParseGDCHServiceAccountCredentials(contents, path); + // if (!info) return std::move(info).status(); + // return std::unique_ptr( + // std::make_unique( + // *info, options, std::move(client_factory))); } return internal::InvalidArgumentError( From 0e7771d2cce23cba395112bdeb53a24ceb29ed57 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Tue, 21 Apr 2026 16:08:30 -0400 Subject: [PATCH 3/9] updated unit tests --- ...2_gdch_service_account_credentials_test.cc | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 025975abb189f..f8b56ab872c7d 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -171,9 +171,9 @@ TEST(GDCHServiceAccountCredentialsTest, // Calls Refresh to obtain the access token for our authorization header. auto token = (*credentials)->GetToken(tp); ASSERT_STATUS_OK(token); - EXPECT_EQ(token->token, "access-token-value"); - EXPECT_EQ(token->expiration, tp + std::chrono::seconds(3599)); - + EXPECT_THAT(token->token, Eq("access-token-value")); + EXPECT_THAT(token->expiration, Eq(tp + std::chrono::seconds(3599))); + EXPECT_THAT((*credentials)->AccountEmail(), Eq(kServiceIdentityName)); EXPECT_THAT((*credentials)->KeyId(), Eq(kPrivateKeyId)); } @@ -202,12 +202,12 @@ TEST(GDCHServiceAccountCredentialsTest, ParseSimple) { auto actual = ParseGDCHServiceAccountCredentials(contents, "test-data"); ASSERT_STATUS_OK(actual); - EXPECT_EQ("test-project-id", actual->project_id); - EXPECT_EQ("not-a-key-id-just-for-testing", actual->private_key_id); - EXPECT_EQ("not-a-valid-key-just-for-testing", actual->private_key); - EXPECT_EQ("test-service-identity", actual->service_identity_name); - EXPECT_EQ("/test/ca.crt", actual->ca_cert_path); - EXPECT_EQ("https://gdc.token.uri/v1/token", actual->token_uri); + EXPECT_THAT(actual->project_id, Eq("test-project-id")); + EXPECT_THAT(actual->private_key_id, Eq("not-a-key-id-just-for-testing")); + EXPECT_THAT(actual->private_key, Eq("not-a-valid-key-just-for-testing")); + EXPECT_THAT(actual->service_identity_name, Eq("test-service-identity")); + EXPECT_THAT(actual->ca_cert_path, Eq("/test/ca.crt")); + EXPECT_THAT(actual->token_uri, Eq("https://gdc.token.uri/v1/token")); } /// @test Verify that invalid contents result in a readable error. @@ -313,8 +313,9 @@ TEST(GDCHServiceAccountCredentialsTest, MissingAudienceOption) { CreateGDCHServiceAccountCredentialsFromJsonContents( MakeTestContents(), Options{}, mock_http_client_factory.AsStdFunction()); - EXPECT_THAT(credentials, StatusIs(StatusCode::kInvalidArgument, - HasSubstr("requires the AudienceOption to be set"))); + EXPECT_THAT(credentials, + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("requires the AudienceOption to be set"))); } /// @test Verify we can obtain JWT assertion components given the info parsed @@ -326,20 +327,20 @@ TEST(GDCHServiceAccountCredentialsTest, AssertionComponentsFromInfo) { auto components = GDCHAssertionComponentsFromInfo(*info, now); auto header = nlohmann::json::parse(components.first); - EXPECT_EQ("RS256", header.value("alg", "")); - EXPECT_EQ("JWT", header.value("typ", "")); - EXPECT_EQ(info->private_key_id, header.value("kid", "")); + EXPECT_THAT(header.value("alg", ""), Eq("RS256")); + EXPECT_THAT(header.value("typ", ""), Eq("JWT")); + EXPECT_THAT(header.value("kid", ""), Eq(info->private_key_id)); auto payload = nlohmann::json::parse(components.second); - EXPECT_EQ(std::chrono::system_clock::to_time_t(now), payload.value("iat", 0)); - EXPECT_EQ( - std::chrono::system_clock::to_time_t(now + std::chrono::seconds(3600)), - payload.value("exp", 0)); + EXPECT_THAT(payload.value("iat", 0), + Eq(std::chrono::system_clock::to_time_t(now))); + EXPECT_THAT(payload.value("exp", 0), Eq(std::chrono::system_clock::to_time_t( + now + std::chrono::seconds(3600)))); auto const iss_sub_value = absl::StrCat("system:serviceaccount:", kProjectId, ":", kServiceIdentityName); - EXPECT_EQ(iss_sub_value, payload.value("iss", "")); - EXPECT_EQ(iss_sub_value, payload.value("sub", "")); - EXPECT_EQ(info->token_uri, payload.value("aud", "")); + EXPECT_THAT(payload.value("iss", ""), Eq(iss_sub_value)); + EXPECT_THAT(payload.value("sub", ""), Eq(iss_sub_value)); + EXPECT_THAT(payload.value("aud", ""), Eq(info->token_uri)); } /// @test Verify we can construct a JWT assertion given the info parsed from a @@ -354,7 +355,7 @@ TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { info->private_key); std::vector actual_tokens = absl::StrSplit(assertion, '.'); - ASSERT_EQ(actual_tokens.size(), 3); + ASSERT_THAT(actual_tokens.size(), Eq(3)); std::vector> decoded(actual_tokens.size()); std::transform( actual_tokens.begin(), actual_tokens.end(), decoded.begin(), @@ -364,14 +365,14 @@ TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { auto const signature = SignUsingSha256(actual_tokens[0] + '.' + actual_tokens[1], kPrivateKey); ASSERT_STATUS_OK(signature); - EXPECT_EQ(*signature, decoded[2]); + EXPECT_THAT(*signature, Eq(decoded[2])); // Verify the header and payloads are valid. auto const header = nlohmann::json::parse(decoded[0].begin(), decoded[0].end()); auto const expected_header = nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; - EXPECT_EQ(header, expected_header); + EXPECT_THAT(header, Eq(expected_header)); auto const payload = nlohmann::json::parse(decoded[1]); auto const iat = static_cast(kFixedJwtTimestamp); @@ -383,7 +384,7 @@ TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { {"iat", iat}, {"exp", exp}, }; - EXPECT_EQ(payload, expected_payload); + EXPECT_THAT(payload, Eq(expected_payload)); } /// @test Verify we can construct a service account refresh payload given the @@ -466,11 +467,10 @@ TEST(GDCHServiceAccountCredentialsTest, .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r1)))); auto const now = std::chrono::system_clock::now(); - auto status = ParseGDCHServiceAccountRefreshResponse(*mock_response1, now); - EXPECT_STATUS_OK(status); - auto token = *status; - EXPECT_EQ(token.expiration, now + expires_in); - EXPECT_EQ(token.token, "access-token-r1"); + auto token = ParseGDCHServiceAccountRefreshResponse(*mock_response1, now); + ASSERT_STATUS_OK(token); + EXPECT_THAT(token->expiration, Eq(now + expires_in)); + EXPECT_THAT(token->token, Eq("access-token-r1")); } } // namespace From f2b16870531c983de247653cd02fa30afd62bbe9 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Tue, 21 Apr 2026 16:13:59 -0400 Subject: [PATCH 4/9] rename factory functions --- .../internal/oauth2_gdch_service_account_credentials.cc | 6 +++--- .../internal/oauth2_gdch_service_account_credentials.h | 4 ++-- .../oauth2_gdch_service_account_credentials_test.cc | 6 +++--- google/cloud/internal/oauth2_google_credentials.cc | 5 ++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc index 2a239a57aaf9d..d1bd914027771 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -195,7 +195,7 @@ StatusOr ParseGDCHServiceAccountRefreshResponse( } StatusOr> GDCHServiceAccountCredentials:: - CreateGDCHServiceAccountCredentialsFromJsonContents( + CreateFromJsonContents( std::string const& contents, Options const& options, HttpClientFactory client_factory) { if (!options.has()) { @@ -219,7 +219,7 @@ StatusOr> GDCHServiceAccountCredentials:: } StatusOr> -GDCHServiceAccountCredentials::CreateGDCHServiceAccountCredentialsFromFilePath( +GDCHServiceAccountCredentials::CreateFromFilePath( std::string const& path, Options const& options, HttpClientFactory client_factory) { std::ifstream is(path); @@ -230,7 +230,7 @@ GDCHServiceAccountCredentials::CreateGDCHServiceAccountCredentialsFromFilePath( GCP_ERROR_INFO()); } std::string contents(std::istreambuf_iterator{is}, {}); - return CreateGDCHServiceAccountCredentialsFromJsonContents( + return CreateFromJsonContents( std::move(contents), options, std::move(client_factory)); } diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.h b/google/cloud/internal/oauth2_gdch_service_account_credentials.h index 2ba3f9bd4a5c4..33d9781cc81d9 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.h +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -147,7 +147,7 @@ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { * Creates a GDCHServiceAccountCredentials from a JSON string. */ static StatusOr> - CreateGDCHServiceAccountCredentialsFromJsonContents( + CreateFromJsonContents( std::string const& contents, Options const& options, HttpClientFactory client_factory); @@ -156,7 +156,7 @@ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { */ static StatusOr> - CreateGDCHServiceAccountCredentialsFromFilePath( + CreateFromFilePath( std::string const& path, Options const& options, HttpClientFactory client_factory); diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index f8b56ab872c7d..9bfa8293ac492 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -163,7 +163,7 @@ TEST(GDCHServiceAccountCredentialsTest, .WillOnce(Return(ByMove(std::move(token_client)))); auto credentials = GDCHServiceAccountCredentials:: - CreateGDCHServiceAccountCredentialsFromJsonContents( + CreateFromJsonContents( MakeTestContents(), Options{}.set(kAudience), mock_client_factory.AsStdFunction()); ASSERT_STATUS_OK(credentials); @@ -297,7 +297,7 @@ TEST(GDCHServiceAccountCredentialsTest, ProjectIdDefined) { EXPECT_CALL(mock_http_client_factory, Call).Times(0); auto credentials = GDCHServiceAccountCredentials:: - CreateGDCHServiceAccountCredentialsFromJsonContents( + CreateFromJsonContents( MakeTestContents(), Options{}.set(kAudience), mock_http_client_factory.AsStdFunction()); ASSERT_STATUS_OK(credentials); @@ -310,7 +310,7 @@ TEST(GDCHServiceAccountCredentialsTest, MissingAudienceOption) { EXPECT_CALL(mock_http_client_factory, Call).Times(0); auto credentials = GDCHServiceAccountCredentials:: - CreateGDCHServiceAccountCredentialsFromJsonContents( + CreateFromJsonContents( MakeTestContents(), Options{}, mock_http_client_factory.AsStdFunction()); EXPECT_THAT(credentials, diff --git a/google/cloud/internal/oauth2_google_credentials.cc b/google/cloud/internal/oauth2_google_credentials.cc index 6dfcc99f8cb25..9aafc78277f6e 100644 --- a/google/cloud/internal/oauth2_google_credentials.cc +++ b/google/cloud/internal/oauth2_google_credentials.cc @@ -100,9 +100,8 @@ StatusOr> LoadCredsFromString( config, std::move(rest_stub))); } if (cred_type == "gdch_service_account") { - return GDCHServiceAccountCredentials:: - CreateGDCHServiceAccountCredentialsFromJsonContents( - contents, options, std::move(client_factory)); + return GDCHServiceAccountCredentials::CreateFromJsonContents( + contents, options, std::move(client_factory)); // auto info = ParseGDCHServiceAccountCredentials(contents, path); // if (!info) return std::move(info).status(); // return std::unique_ptr( From fcea06265ac74edbe2b091f5e6a7d24a0713b69c Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Tue, 21 Apr 2026 17:04:06 -0400 Subject: [PATCH 5/9] make free stuff static members --- ...oauth2_gdch_service_account_credentials.cc | 84 +++++------ .../oauth2_gdch_service_account_credentials.h | 134 ++++++++---------- ...2_gdch_service_account_credentials_test.cc | 71 +++++----- .../internal/oauth2_google_credentials.cc | 11 +- 4 files changed, 147 insertions(+), 153 deletions(-) diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc index d1bd914027771..0944b907bafc3 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -35,8 +35,9 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN using ::google::cloud::internal::MakeJWTAssertionNoThrow; -StatusOr ParseGDCHServiceAccountCredentials( - std::string const& content, std::string const& source) { +StatusOr +GDCHServiceAccountCredentials::Parse(std::string const& content, + std::string const& source) { auto credentials = nlohmann::json::parse(content, nullptr, false); if (credentials.is_discarded()) { return internal::InvalidArgumentError(absl::StrCat( @@ -46,7 +47,7 @@ StatusOr ParseGDCHServiceAccountCredentials( using Validator = std::function; - using Store = std::function; auto non_empty_field = [&](absl::string_view name, nlohmann::json::iterator const& l) { @@ -72,39 +73,39 @@ StatusOr ParseGDCHServiceAccountCredentials( Store store; }; std::vector fields{{"project_id", required_field, - [](GDCHServiceAccountCredentialsInfo& info, + [](GDCHServiceAccountCredentials::Info& info, nlohmann::json::iterator const& l) { info.project_id = l->get(); }}, {"private_key_id", required_field, - [&](GDCHServiceAccountCredentialsInfo& info, + [&](GDCHServiceAccountCredentials::Info& info, nlohmann::json::iterator const& l) { if (l == credentials.end()) return; info.private_key_id = l->get(); }}, {"private_key", required_field, - [](GDCHServiceAccountCredentialsInfo& info, + [](GDCHServiceAccountCredentials::Info& info, nlohmann::json::iterator const& l) { info.private_key = l->get(); }}, {"name", required_field, - [&](GDCHServiceAccountCredentialsInfo& info, + [&](GDCHServiceAccountCredentials::Info& info, nlohmann::json::iterator const& l) { info.service_identity_name = l->get(); }}, {"ca_cert_path", required_field, - [&](GDCHServiceAccountCredentialsInfo& info, + [&](GDCHServiceAccountCredentials::Info& info, nlohmann::json::iterator const& l) { info.ca_cert_path = l->get(); }}, {"token_uri", required_field, - [&](GDCHServiceAccountCredentialsInfo& info, + [&](GDCHServiceAccountCredentials::Info& info, nlohmann::json::iterator const& l) { info.token_uri = l->get(); }}}; - auto info = GDCHServiceAccountCredentialsInfo{}; + auto info = GDCHServiceAccountCredentials::Info{}; for (auto& f : fields) { auto l = credentials.find(f.name); if (l != credentials.end() && !l->is_string()) { @@ -120,8 +121,9 @@ StatusOr ParseGDCHServiceAccountCredentials( return info; } -std::pair GDCHAssertionComponentsFromInfo( - GDCHServiceAccountCredentialsInfo const& info, +std::pair +GDCHServiceAccountCredentials::AssertionComponentsFromInfo( + GDCHServiceAccountCredentials::Info const& info, std::chrono::system_clock::time_point now) { nlohmann::json assertion_header = {{"alg", "RS256"}, {"typ", "JWT"}}; if (!info.private_key_id.empty()) { @@ -151,28 +153,27 @@ std::pair GDCHAssertionComponentsFromInfo( return std::make_pair(assertion_header.dump(), assertion_payload.dump()); } -std::string MakeGDCHJWTAssertion(std::string const& header, - std::string const& payload, - std::string const& pem_contents) { +std::string GDCHServiceAccountCredentials::MakeJWTAssertion( + std::string const& header, std::string const& payload, + std::string const& pem_contents) { return internal::MakeJWTAssertionNoThrow(header, payload, pem_contents) .value(); } std::vector> -CreateGDCHServiceAccountRefreshPayload( - GDCHServiceAccountCredentialsInfo const& info, +GDCHServiceAccountCredentials::CreateRefreshPayload( + GDCHServiceAccountCredentials::Info const& info, std::chrono::system_clock::time_point now) { - auto [header, payload] = GDCHAssertionComponentsFromInfo(info, now); + auto [header, payload] = AssertionComponentsFromInfo(info, now); return { {"grant_type", "urn:ietf:params:oauth:token-type:token-exchange"}, {"audience", info.audience}, {"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"}, - {"subject_token", - MakeGDCHJWTAssertion(header, payload, info.private_key)}, + {"subject_token", MakeJWTAssertion(header, payload, info.private_key)}, {"subject_token_type", "urn:k8s:params:oauth:token-type:serviceaccount"}}; } -StatusOr ParseGDCHServiceAccountRefreshResponse( +StatusOr GDCHServiceAccountCredentials::ParseRefreshResponse( rest_internal::RestResponse& response, std::chrono::system_clock::time_point now) { auto status_code = response.StatusCode(); @@ -194,30 +195,35 @@ StatusOr ParseGDCHServiceAccountRefreshResponse( return AccessToken{access_token.value("access_token", ""), now + expires_in}; } -StatusOr> GDCHServiceAccountCredentials:: - CreateFromJsonContents( - std::string const& contents, Options const& options, - HttpClientFactory client_factory) { +StatusOr> +GDCHServiceAccountCredentials::CreateFromInfo( + Info info, Options const& options, HttpClientFactory client_factory) { if (!options.has()) { return internal::InvalidArgumentError( "Creation of GDCH Service Account credentials requires the " "AudienceOption to be set.", GCP_ERROR_INFO()); } - auto info = ParseGDCHServiceAccountCredentials(contents, "memory"); - if (!info) return info.status(); // Verify this is usable before returning it. auto const tp = std::chrono::system_clock::time_point{}; - auto const components = GDCHAssertionComponentsFromInfo(*info, tp); - auto jwt = MakeJWTAssertionNoThrow(components.first, components.second, - info->private_key); + auto const [header, payload] = AssertionComponentsFromInfo(info, tp); + auto jwt = MakeJWTAssertionNoThrow(header, payload, info.private_key); if (!jwt) return jwt.status(); return StatusOr>( std::unique_ptr( - new GDCHServiceAccountCredentials(*info, options, + new GDCHServiceAccountCredentials(std::move(info), options, std::move(client_factory)))); } +StatusOr> +GDCHServiceAccountCredentials::CreateFromJsonContents( + std::string const& contents, Options const& options, + HttpClientFactory client_factory) { + auto info = Parse(contents, "memory"); + if (!info) return info.status(); + return CreateFromInfo(*std::move(info), options, std::move(client_factory)); +} + StatusOr> GDCHServiceAccountCredentials::CreateFromFilePath( std::string const& path, Options const& options, @@ -230,18 +236,14 @@ GDCHServiceAccountCredentials::CreateFromFilePath( GCP_ERROR_INFO()); } std::string contents(std::istreambuf_iterator{is}, {}); - return CreateFromJsonContents( - std::move(contents), options, std::move(client_factory)); + return CreateFromJsonContents(std::move(contents), options, + std::move(client_factory)); } GDCHServiceAccountCredentials::GDCHServiceAccountCredentials( - GDCHServiceAccountCredentialsInfo info, Options options, - HttpClientFactory client_factory) + Info info, Options options, HttpClientFactory client_factory) : info_(std::move(info)), - options_(internal::MergeOptions( - std::move(options), - Options{}.set( - info_.token_uri))), + options_(std::move(options)), client_factory_(std::move(client_factory)) { if (options_.has()) { info_.audience = options_.get(); @@ -253,12 +255,12 @@ StatusOr GDCHServiceAccountCredentials::GetToken( auto client = client_factory_(options_); rest_internal::RestRequest request; request.SetPath(info_.token_uri); - auto payload = CreateGDCHServiceAccountRefreshPayload(info_, tp); + auto payload = CreateRefreshPayload(info_, tp); rest_internal::RestContext context; auto response = client->Post(context, request, payload); if (!response) return std::move(response).status(); if (IsHttpError(**response)) return AsStatus(std::move(**response)); - return ParseServiceAccountRefreshResponse(**response, tp); + return ParseRefreshResponse(**response, tp); } StatusOr GDCHServiceAccountCredentials::project_id() const { diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.h b/google/cloud/internal/oauth2_gdch_service_account_credentials.h index 33d9781cc81d9..c862ac67883d3 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.h +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -30,56 +30,6 @@ namespace cloud { namespace oauth2_internal { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN -/// Object to hold information used to instantiate an -/// GDCHServiceAccountCredentials. -struct GDCHServiceAccountCredentialsInfo { - // From json file - std::string project_id; - std::string private_key_id; - std::string private_key; - std::string service_identity_name; - std::string ca_cert_path; - std::string token_uri; - - // Additional data provided by the user. - std::string audience; -}; - -/// Parses a refresh response JSON string to create an access token. -StatusOr ParseGDCHServiceAccountRefreshResponse( - rest_internal::RestResponse& response, - std::chrono::system_clock::time_point now); - -/** - * Splits a GDCHServiceAccountCredentialsInfo into header and payload components - * and uses the current time to make a JWT assertion. - * - * @see - * https://cloud.google.com/endpoints/docs/frameworks/java/troubleshoot-jwt - * - * @see https://tools.ietf.org/html/rfc7523 - */ -std::pair GDCHAssertionComponentsFromInfo( - GDCHServiceAccountCredentialsInfo const& info, - std::chrono::system_clock::time_point now); - -/** - * Given a key and a JSON header and payload, creates a JWT assertion string. - * - * @see https://tools.ietf.org/html/rfc7519 - */ -std::string MakeGDCHJWTAssertion(std::string const& header, - std::string const& payload, - std::string const& pem_contents); - -/// Uses a GDCHServiceAccountCredentialsInfo and the current time to construct a -/// JWT assertion. The assertion combined with the grant type is used to create -/// the refresh payload. -std::vector> -CreateGDCHServiceAccountRefreshPayload( - GDCHServiceAccountCredentialsInfo const& info, - std::chrono::system_clock::time_point now); - /** * Implements GDCH service account credentials for REST clients. * @@ -143,26 +93,79 @@ CreateGDCHServiceAccountRefreshPayload( */ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { public: + /// Object to hold information used to instantiate an + /// GDCHServiceAccountCredentials. + struct Info { + // From json file + std::string project_id; + std::string private_key_id; + std::string private_key; + std::string service_identity_name; + std::string ca_cert_path; + std::string token_uri; + + // Additional data provided by the user. + std::string audience; + }; + + /// Parses the contents of a JSON keyfile into a + /// GDCHServiceAccountCredentialsInfo. + static StatusOr Parse(std::string const& content, + std::string const& source); + + /** + * Creates a GDCHServiceAccountCredentials from a + * GDCHServiceAccountCredentialsInfo. + */ + static StatusOr> CreateFromInfo( + Info info, Options const& options, HttpClientFactory client_factory); + /** * Creates a GDCHServiceAccountCredentials from a JSON string. */ - static StatusOr> - CreateFromJsonContents( + static StatusOr> CreateFromJsonContents( std::string const& contents, Options const& options, HttpClientFactory client_factory); /** * Creates a GDCHServiceAccountCredentials from a file at the specified path. */ - - static StatusOr> - CreateFromFilePath( + static StatusOr> CreateFromFilePath( std::string const& path, Options const& options, HttpClientFactory client_factory); + /// Parses a refresh response JSON string to create an access token. + static StatusOr ParseRefreshResponse( + rest_internal::RestResponse& response, + std::chrono::system_clock::time_point now); + /** - * Returns a key value pair for an "Authorization" header. + * Splits a GDCHServiceAccountCredentialsInfo into header and payload + * components and uses the current time to make a JWT assertion. + * + * @see + * https://cloud.google.com/endpoints/docs/frameworks/java/troubleshoot-jwt + * + * @see https://tools.ietf.org/html/rfc7523 */ + static std::pair AssertionComponentsFromInfo( + Info const& info, std::chrono::system_clock::time_point now); + + /** + * Given a key and a JSON header and payload, creates a JWT assertion string. + * + * @see https://tools.ietf.org/html/rfc7519 + */ + static std::string MakeJWTAssertion(std::string const& header, + std::string const& payload, + std::string const& pem_contents); + + /// Uses a GDCHServiceAccountCredentialsInfo and the current time to construct + /// a JWT assertion. The assertion combined with the grant type is used to + /// create the refresh payload. + static std::vector> CreateRefreshPayload( + Info const& info, std::chrono::system_clock::time_point now); + StatusOr GetToken( std::chrono::system_clock::time_point tp) override; @@ -176,29 +179,14 @@ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { StatusOr project_id(Options const&) const override; private: - /** - * Creates an instance of GDCHServiceAccountCredentials. - * - * @param rest_client a dependency injection point. It makes it possible to - * mock internal REST types. This should generally not be overridden - * except for testing. - * @param current_time_fn a dependency injection point to fetch the current - * time. This should generally not be overridden except for testing. - */ - GDCHServiceAccountCredentials(GDCHServiceAccountCredentialsInfo info, - Options options, + GDCHServiceAccountCredentials(Info info, Options options, HttpClientFactory client_factory); - GDCHServiceAccountCredentialsInfo info_; + Info info_; Options options_; HttpClientFactory client_factory_; }; -/// Parses the contents of a JSON keyfile into a -/// GDCHServiceAccountCredentialsInfo. -StatusOr ParseGDCHServiceAccountCredentials( - std::string const& content, std::string const& source); - GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 9bfa8293ac492..17943c85fb340 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -128,7 +128,7 @@ TEST(GDCHServiceAccountCredentialsTest, {"iss", iss_sub_value}, {"sub", iss_sub_value}, {"aud", kTokenUri}, {"iat", iat}, {"exp", exp}, }; - auto const assertion = MakeGDCHJWTAssertion( + auto const assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( expected_header.dump(), expected_payload.dump(), kPrivateKey); auto token_client = [=] { @@ -162,10 +162,9 @@ TEST(GDCHServiceAccountCredentialsTest, EXPECT_CALL(mock_client_factory, Call) .WillOnce(Return(ByMove(std::move(token_client)))); - auto credentials = GDCHServiceAccountCredentials:: - CreateFromJsonContents( - MakeTestContents(), Options{}.set(kAudience), - mock_client_factory.AsStdFunction()); + auto credentials = GDCHServiceAccountCredentials::CreateFromJsonContents( + MakeTestContents(), Options{}.set(kAudience), + mock_client_factory.AsStdFunction()); ASSERT_STATUS_OK(credentials); auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); // Calls Refresh to obtain the access token for our authorization header. @@ -200,7 +199,7 @@ TEST(GDCHServiceAccountCredentialsTest, ParseSimple) { "token_uri": "https://gdc.token.uri/v1/token" })"""; - auto actual = ParseGDCHServiceAccountCredentials(contents, "test-data"); + auto actual = GDCHServiceAccountCredentials::Parse(contents, "test-data"); ASSERT_STATUS_OK(actual); EXPECT_THAT(actual->project_id, Eq("test-project-id")); EXPECT_THAT(actual->private_key_id, Eq("not-a-key-id-just-for-testing")); @@ -214,7 +213,8 @@ TEST(GDCHServiceAccountCredentialsTest, ParseSimple) { TEST(GDCHServiceAccountCredentialsTest, ParseInvalidContentsFails) { std::string config = R"""( not-a-valid-json-string )"""; - auto actual = ParseGDCHServiceAccountCredentials(config, "test-as-a-source"); + auto actual = + GDCHServiceAccountCredentials::Parse(config, "test-as-a-source"); EXPECT_THAT(actual, StatusIs(Not(StatusCode::kOk), AllOf(HasSubstr("Invalid GDCHServiceAccountCredentials"), @@ -236,7 +236,8 @@ TEST(GDCHServiceAccountCredentialsTest, ParseEmptyFieldFails) { "name", "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json[field] = ""; - auto actual = ParseGDCHServiceAccountCredentials(json.dump(), "test-data"); + auto actual = + GDCHServiceAccountCredentials::Parse(json.dump(), "test-data"); EXPECT_THAT(actual, StatusIs(Not(StatusCode::kOk), AllOf(HasSubstr(field), HasSubstr(" field is empty"), @@ -259,7 +260,8 @@ TEST(GDCHServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { "name", "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json[field] = true; - auto actual = ParseGDCHServiceAccountCredentials(json.dump(), "test-data"); + auto actual = + GDCHServiceAccountCredentials::Parse(json.dump(), "test-data"); EXPECT_THAT( actual, StatusIs(Not(StatusCode::kOk), @@ -284,7 +286,8 @@ TEST(GDCHServiceAccountCredentialsTest, ParseMissingFieldFails) { "name", "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json.erase(field); - auto actual = ParseGDCHServiceAccountCredentials(json.dump(), "test-data"); + auto actual = + GDCHServiceAccountCredentials::Parse(json.dump(), "test-data"); EXPECT_THAT(actual, StatusIs(Not(StatusCode::kOk), AllOf(HasSubstr(field), HasSubstr(" field is missing"), @@ -296,10 +299,9 @@ TEST(GDCHServiceAccountCredentialsTest, ProjectIdDefined) { MockHttpClientFactory mock_http_client_factory; EXPECT_CALL(mock_http_client_factory, Call).Times(0); - auto credentials = GDCHServiceAccountCredentials:: - CreateFromJsonContents( - MakeTestContents(), Options{}.set(kAudience), - mock_http_client_factory.AsStdFunction()); + auto credentials = GDCHServiceAccountCredentials::CreateFromJsonContents( + MakeTestContents(), Options{}.set(kAudience), + mock_http_client_factory.AsStdFunction()); ASSERT_STATUS_OK(credentials); EXPECT_THAT((*credentials)->project_id(), IsOkAndHolds(kProjectId)); EXPECT_THAT((*credentials)->project_id({}), IsOkAndHolds(kProjectId)); @@ -309,10 +311,8 @@ TEST(GDCHServiceAccountCredentialsTest, MissingAudienceOption) { MockHttpClientFactory mock_http_client_factory; EXPECT_CALL(mock_http_client_factory, Call).Times(0); - auto credentials = GDCHServiceAccountCredentials:: - CreateFromJsonContents( - MakeTestContents(), Options{}, - mock_http_client_factory.AsStdFunction()); + auto credentials = GDCHServiceAccountCredentials::CreateFromJsonContents( + MakeTestContents(), Options{}, mock_http_client_factory.AsStdFunction()); EXPECT_THAT(credentials, StatusIs(StatusCode::kInvalidArgument, HasSubstr("requires the AudienceOption to be set"))); @@ -321,10 +321,11 @@ TEST(GDCHServiceAccountCredentialsTest, MissingAudienceOption) { /// @test Verify we can obtain JWT assertion components given the info parsed /// from a keyfile. TEST(GDCHServiceAccountCredentialsTest, AssertionComponentsFromInfo) { - auto info = ParseGDCHServiceAccountCredentials(MakeTestContents(), "test"); + auto info = GDCHServiceAccountCredentials::Parse(MakeTestContents(), "test"); ASSERT_STATUS_OK(info); auto const now = std::chrono::system_clock::now(); - auto components = GDCHAssertionComponentsFromInfo(*info, now); + auto components = + GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, now); auto header = nlohmann::json::parse(components.first); EXPECT_THAT(header.value("alg", ""), Eq("RS256")); @@ -346,13 +347,14 @@ TEST(GDCHServiceAccountCredentialsTest, AssertionComponentsFromInfo) { /// @test Verify we can construct a JWT assertion given the info parsed from a /// keyfile. TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { - auto info = ParseGDCHServiceAccountCredentials(MakeTestContents(), "test"); + auto info = GDCHServiceAccountCredentials::Parse(MakeTestContents(), "test"); ASSERT_STATUS_OK(info); auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); - auto components = GDCHAssertionComponentsFromInfo(*info, tp); - auto assertion = MakeGDCHJWTAssertion(components.first, components.second, - info->private_key); + auto components = + GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, tp); + auto assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( + components.first, components.second, info->private_key); std::vector actual_tokens = absl::StrSplit(assertion, '.'); ASSERT_THAT(actual_tokens.size(), Eq(3)); @@ -391,14 +393,16 @@ TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { /// info parsed from a keyfile. TEST(GDCHServiceAccountCredentialsTest, CreateGDCHServiceAccountRefreshPayload) { - auto info = ParseGDCHServiceAccountCredentials(MakeTestContents(), "test"); + auto info = GDCHServiceAccountCredentials::Parse(MakeTestContents(), "test"); ASSERT_STATUS_OK(info); info->audience = kAudience; auto const now = std::chrono::system_clock::now(); - auto components = GDCHAssertionComponentsFromInfo(*info, now); - auto assertion = MakeGDCHJWTAssertion(components.first, components.second, - info->private_key); - auto actual_payload = CreateGDCHServiceAccountRefreshPayload(*info, now); + auto components = + GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, now); + auto assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( + components.first, components.second, info->private_key); + auto actual_payload = + GDCHServiceAccountCredentials::CreateRefreshPayload(*info, now); EXPECT_THAT( actual_payload, @@ -438,12 +442,14 @@ TEST(GDCHServiceAccountCredentialsTest, .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r2)))); auto const now = std::chrono::system_clock::now(); - auto status = ParseGDCHServiceAccountRefreshResponse(*mock_response1, now); + auto status = + GDCHServiceAccountCredentials::ParseRefreshResponse(*mock_response1, now); EXPECT_THAT(status, StatusIs(StatusCode::kInvalidArgument, HasSubstr("Could not find all required fields"))); - status = ParseGDCHServiceAccountRefreshResponse(*mock_response2, now); + status = + GDCHServiceAccountCredentials::ParseRefreshResponse(*mock_response2, now); EXPECT_THAT(status, StatusIs(StatusCode::kInvalidArgument, HasSubstr("Could not find all required fields"))); @@ -467,7 +473,8 @@ TEST(GDCHServiceAccountCredentialsTest, .WillOnce(Return(ByMove(MakeMockHttpPayloadSuccess(r1)))); auto const now = std::chrono::system_clock::now(); - auto token = ParseGDCHServiceAccountRefreshResponse(*mock_response1, now); + auto token = + GDCHServiceAccountCredentials::ParseRefreshResponse(*mock_response1, now); ASSERT_STATUS_OK(token); EXPECT_THAT(token->expiration, Eq(now + expires_in)); EXPECT_THAT(token->token, Eq("access-token-r1")); diff --git a/google/cloud/internal/oauth2_google_credentials.cc b/google/cloud/internal/oauth2_google_credentials.cc index 9aafc78277f6e..4fa1d328f461a 100644 --- a/google/cloud/internal/oauth2_google_credentials.cc +++ b/google/cloud/internal/oauth2_google_credentials.cc @@ -100,13 +100,10 @@ StatusOr> LoadCredsFromString( config, std::move(rest_stub))); } if (cred_type == "gdch_service_account") { - return GDCHServiceAccountCredentials::CreateFromJsonContents( - contents, options, std::move(client_factory)); - // auto info = ParseGDCHServiceAccountCredentials(contents, path); - // if (!info) return std::move(info).status(); - // return std::unique_ptr( - // std::make_unique( - // *info, options, std::move(client_factory))); + auto info = GDCHServiceAccountCredentials::Parse(contents, path); + if (!info) return std::move(info).status(); + return GDCHServiceAccountCredentials::CreateFromInfo( + *std::move(info), options, std::move(client_factory)); } return internal::InvalidArgumentError( From 07961ea52b1fd8c150381dbba452110be4945fae Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Thu, 21 May 2026 15:05:18 -0400 Subject: [PATCH 6/9] working GetToken --- .../ubuntu-20.04-install.Dockerfile | 6 + google/cloud/credentials.cc | 6 + google/cloud/credentials.h | 34 +++++ google/cloud/internal/credentials_impl.cc | 14 ++ google/cloud/internal/credentials_impl.h | 41 ++++-- .../cloud/internal/credentials_impl_test.cc | 13 ++ google/cloud/internal/curl_rest_client.cc | 71 +++++++++- google/cloud/internal/curl_rest_client.h | 3 + google/cloud/internal/make_jwt_assertion.cc | 11 +- google/cloud/internal/make_jwt_assertion.h | 7 +- ...oauth2_gdch_service_account_credentials.cc | 133 ++++++++++-------- .../oauth2_gdch_service_account_credentials.h | 106 ++++---------- ...2_gdch_service_account_credentials_test.cc | 23 ++- .../oauth2_service_account_credentials.cc | 3 +- .../internal/openssl/sign_using_sha256.cc | 74 +++++++++- google/cloud/internal/rest_client.h | 4 + google/cloud/internal/sign_using_sha256.h | 6 +- .../internal/unified_grpc_credentials.cc | 10 ++ .../internal/unified_rest_credentials.cc | 27 ++++ ...ified_rest_credentials_integration_test.cc | 20 +++ .../internal/unified_rest_credentials_test.cc | 123 +++++++++++++++- google/cloud/testing_util/credentials.h | 7 + google/cloud/testing_util/mock_rest_client.h | 5 + 23 files changed, 578 insertions(+), 169 deletions(-) diff --git a/ci/cloudbuild/dockerfiles/ubuntu-20.04-install.Dockerfile b/ci/cloudbuild/dockerfiles/ubuntu-20.04-install.Dockerfile index ac26d636b2c5f..aab4b3413363f 100644 --- a/ci/cloudbuild/dockerfiles/ubuntu-20.04-install.Dockerfile +++ b/ci/cloudbuild/dockerfiles/ubuntu-20.04-install.Dockerfile @@ -13,6 +13,8 @@ # limitations under the License. FROM ubuntu:20.04 +ARG NCPU=4 +ARG ARCH=amd64 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ @@ -199,5 +201,9 @@ RUN curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/scca mv sccache /usr/local/bin/sccache && \ chmod +x /usr/local/bin/sccache +RUN curl -o /usr/bin/bazelisk -sSL "https://github.com/bazelbuild/bazelisk/releases/download/v1.28.1/bazelisk-linux-${ARCH}" && \ + chmod +x /usr/bin/bazelisk && \ + ln -s /usr/bin/bazelisk /usr/bin/bazel + # Update the ld.conf cache in case any libraries were installed in /usr/local/lib* RUN ldconfig /usr/local/lib* diff --git a/google/cloud/credentials.cc b/google/cloud/credentials.cc index 69a757c62b2ea..539e5162f4d98 100644 --- a/google/cloud/credentials.cc +++ b/google/cloud/credentials.cc @@ -74,6 +74,12 @@ std::shared_ptr MakeComputeEngineCredentials(Options opts) { std::move(opts)); } +std::shared_ptr MakeGDCHServiceAccountCredentials( + std::string json_object, std::string audience, Options opts) { + return std::make_shared( + std::move(json_object), std::move(audience), std::move(opts)); +} + GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace cloud } // namespace google diff --git a/google/cloud/credentials.h b/google/cloud/credentials.h index a58f81483d5ba..05dec7c4a5a2f 100644 --- a/google/cloud/credentials.h +++ b/google/cloud/credentials.h @@ -453,6 +453,40 @@ std::shared_ptr MakeApiKeyCredentials(std::string api_key, */ std::shared_ptr MakeComputeEngineCredentials(Options opts = {}); +/** + * Creates credentials for a Google Distributed Cloud Hosting (GDCH) Service + * Account. + * + * @see https://docs.cloud.google.com/distributed-cloud/hosted/docs/latest/gdcag + * for more information on GDCH air-gapped environments. + * + * + * @see https://cloud.google.com/docs/authentication for more information on + * authentication in GCP. + * + * @see https://cloud.google.com/docs/authentication/client-libraries for more + * information on authentication for client libraries. + * + * [aip/4115]: https://google.aip.dev/auth/4115 + * + * @ingroup guac + * + * @param json_object service account configuration as a JSON string. If + * omitted, the contents of the file at GOOGLE_APPLICATION_CREDENTIALS is + * used. + * + * @param audience authentication endpoint for the service identity used if + * AudienceOption not present in opts. + * + * @param opts optional configuration values. Note that the effect of these + * parameters depends on the underlying transport. For example, + * `LoggingComponentsOption` is ignored by gRPC-based services. + */ +std::shared_ptr MakeGDCHServiceAccountCredentials( + std::string json_object, std::string audience, Options opts = {}); +std::shared_ptr MakeGDCHServiceAccountCredentials( + std::string audience, Options opts = {}); + /** * Configure the delegates for `MakeImpersonateServiceAccountCredentials()` * diff --git a/google/cloud/internal/credentials_impl.cc b/google/cloud/internal/credentials_impl.cc index f3a4b8ee9e515..48daa4b3abfc2 100644 --- a/google/cloud/internal/credentials_impl.cc +++ b/google/cloud/internal/credentials_impl.cc @@ -14,6 +14,7 @@ #include "google/cloud/internal/credentials_impl.h" #include "google/cloud/common_options.h" +#include "google/cloud/internal/getenv.h" #include "google/cloud/internal/populate_common_options.h" #include @@ -106,6 +107,19 @@ ApiKeyConfig::ApiKeyConfig(std::string api_key, Options opts) ComputeEngineCredentialsConfig::ComputeEngineCredentialsConfig(Options opts) : options_(PopulateAuthOptions(std::move(opts))) {} +GDCHServiceAccountConfig::GDCHServiceAccountConfig(std::string json_object, + std::string audience, + Options opts) + : json_object_(std::move(json_object)), + audience_(std::move(audience)), + options_(PopulateAuthOptions(std::move(opts))) {} + +GDCHServiceAccountConfig::GDCHServiceAccountConfig(std::string audience, + Options opts) + : file_path_(GetEnv("GOOGLE_APPLICATION_CREDENTIALS").value_or("")), + audience_(std::move(audience)), + options_(PopulateAuthOptions(std::move(opts))) {} + } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace cloud diff --git a/google/cloud/internal/credentials_impl.h b/google/cloud/internal/credentials_impl.h index 4b7eea36ab21e..37435b39f05a3 100644 --- a/google/cloud/internal/credentials_impl.h +++ b/google/cloud/internal/credentials_impl.h @@ -20,10 +20,10 @@ #include "google/cloud/options.h" #include "google/cloud/status_or.h" #include "google/cloud/version.h" -#include "absl/types/optional.h" #include #include #include +#include #include #include @@ -41,6 +41,7 @@ class ServiceAccountConfig; class ExternalAccountConfig; class ApiKeyConfig; class ComputeEngineCredentialsConfig; +class GDCHServiceAccountConfig; std::shared_ptr MakeErrorCredentials(Status error_status); @@ -56,6 +57,7 @@ class CredentialsVisitor { virtual void visit(ExternalAccountConfig const&) = 0; virtual void visit(ApiKeyConfig const&) = 0; virtual void visit(ComputeEngineCredentialsConfig const&) = 0; + virtual void visit(GDCHServiceAccountConfig const&) = 0; static void dispatch(Credentials const& credentials, CredentialsVisitor& visitor); @@ -147,20 +149,18 @@ class ServiceAccountConfig : public Credentials { // Only one of json_object or file_path should have a value. // TODO(#15886): Use the C++ type system to make better constructors that // enforces this comment. - ServiceAccountConfig(absl::optional json_object, - absl::optional file_path, Options opts); + ServiceAccountConfig(std::optional json_object, + std::optional file_path, Options opts); - absl::optional const& json_object() const { - return json_object_; - } - absl::optional const& file_path() const { return file_path_; } + std::optional const& json_object() const { return json_object_; } + std::optional const& file_path() const { return file_path_; } Options const& options() const { return options_; } private: void dispatch(CredentialsVisitor& v) const override { v.visit(*this); } - absl::optional json_object_; - absl::optional file_path_; + std::optional json_object_; + std::optional file_path_; Options options_; }; @@ -206,6 +206,29 @@ class ComputeEngineCredentialsConfig : public Credentials { Options options_; }; +class GDCHServiceAccountConfig : public Credentials { + public: + GDCHServiceAccountConfig(std::string json_object, std::string audience, + Options opts); + GDCHServiceAccountConfig(std::string audience, Options opts); + + ~GDCHServiceAccountConfig() override = default; + + std::optional const& file_path() const { return file_path_; } + std::string const& json_object() const { return json_object_; } + std::string const& audience() const { return audience_; } + Options const& options() const { return options_; } + + private: + void dispatch(CredentialsVisitor& v) const override { v.visit(*this); } + + std::optional file_path_ = std::nullopt; + ; + std::string json_object_; + std::string audience_; + Options options_; +}; + /// A helper function to initialize Auth options. Options PopulateAuthOptions(Options options); diff --git a/google/cloud/internal/credentials_impl_test.cc b/google/cloud/internal/credentials_impl_test.cc index d74aab145cf97..657d875565442 100644 --- a/google/cloud/internal/credentials_impl_test.cc +++ b/google/cloud/internal/credentials_impl_test.cc @@ -124,6 +124,19 @@ TEST(Credentials, ApiKeyCredentials) { EXPECT_EQ("api-key", visitor.api_key); } +TEST(Credentials, GDCHServiceAccountCredentials) { + TestCredentialsVisitor visitor; + + auto credentials = MakeGDCHServiceAccountCredentials( + "test-json", "test-audience", + Options{}.set("test-audience-option")); + CredentialsVisitor::dispatch(*credentials, visitor); + EXPECT_EQ("GDCHServiceAccountConfig", visitor.name); + EXPECT_EQ("test-json", visitor.json_object); + EXPECT_EQ("test-audience", visitor.audience); + EXPECT_EQ("test-audience-option", visitor.options.get()); +} + TEST(PopulateAuthOptions, EmptyOptions) { auto result_options = PopulateAuthOptions(Options{}); diff --git a/google/cloud/internal/curl_rest_client.cc b/google/cloud/internal/curl_rest_client.cc index 0a993ff7c2a86..8fa5fdbd272d9 100644 --- a/google/cloud/internal/curl_rest_client.cc +++ b/google/cloud/internal/curl_rest_client.cc @@ -27,6 +27,7 @@ #include "absl/strings/str_cat.h" #include "absl/strings/str_join.h" #include "absl/strings/strip.h" +#include namespace google { namespace cloud { @@ -61,13 +62,55 @@ Status MakeRequestWithPayload( } std::size_t content_length = 0; + std::cout << __func__ << ": payload=" << std::endl; for (auto const& p : payload) { + std::cout << __func__ << std::string(p.begin(), p.end()) << std::endl; content_length += p.size(); } impl.SetHeader(HttpHeader("Content-Length", std::to_string(content_length))); return impl.MakeRequest(http_method, context, payload); } +#if 0 + +Status MakeRequestWithPayload( + CurlImpl::HttpMethod http_method, RestContext& context, + RestRequest const&, CurlImpl& impl, + nlohmann::json const& json_payload) { + + impl.SetHeader(HttpHeader("content-type", "application/json")); + impl.SetHeader(HttpHeader("content-length", std::to_string(json_payload.size()))); + return impl.MakeRequest(http_method, context, json_payload); + + // If no Content-Type is specified for the payload, default to + // application/x-www-form-urlencoded and encode the payload accordingly before + // making the request. + auto content_type = request.GetHeader("Content-Type"); + if (content_type.empty()) content_type = context.GetHeader("Content-Type"); + if (content_type.empty()) { + std::string encoded_payload; + impl.SetHeader( + HttpHeader("content-type", "application/x-www-form-urlencoded")); + std::string concatenated_payload; + for (auto const& p : payload) { + concatenated_payload += std::string(p.begin(), p.end()); + } + encoded_payload = impl.MakeEscapedString(concatenated_payload); + impl.SetHeader( + HttpHeader("Content-Length", std::to_string(encoded_payload.size()))); + return impl.MakeRequest(http_method, context, + {{encoded_payload.data(), encoded_payload.size()}}); + } + + std::size_t content_length = 0; + for (auto const& p : payload) { + content_length += p.size(); + } + + impl.SetHeader(HttpHeader("Content-Length", std::to_string(content_length))); + return impl.MakeRequest(http_method, context, payload); +} +#endif std::string FormatHostHeaderValue(absl::string_view hostname) { if (!absl::ConsumePrefix(&hostname, "https://")) { @@ -194,6 +237,12 @@ StatusOr> CurlRestClient::Post( auto options = internal::MergeOptions(context.options(), options_); auto impl = CreateCurlImpl(context, request, options); if (!impl.ok()) return impl.status(); + std::cout << __func__ << ": payload=Spans" << std::endl; + // for (auto const& p : payload) { + // std::cout << __func__ << ": payload=" << std::string(p.begin(), p.end()) + // << + // std::endl; + // } Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, request, **impl, payload); if (!response.ok()) return response; @@ -214,13 +263,31 @@ StatusOr> CurlRestClient::Post( out->append( absl::StrCat(i.first, "=", (*impl)->MakeEscapedString(i.second))); }); - Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, - request, **impl, {form_payload}); + std::cout << __func__ << ": form_payload=" << form_payload << std::endl; + std::vector> span_payload{form_payload}; + Status response = + MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, request, + **impl, std::move(span_payload)); if (!response.ok()) return response; return {std::unique_ptr( new CurlRestResponse(std::move(options), std::move(*impl)))}; } +// StatusOr> CurlRestClient::Post( +// RestContext& context, RestRequest const& request, +// nlohmann::json const& json_payload) { +// context.AddHeader("content-type", "application/json"); +// auto options = internal::MergeOptions(context.options(), options_); +// auto impl = CreateCurlImpl(context, request, options); +// if (!impl.ok()) return impl.status(); +// Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, +// context, +// request, **impl, json_payload); +// if (!response.ok()) return response; +// return {std::unique_ptr( +// new CurlRestResponse(std::move(options), std::move(*impl)))}; +// } + StatusOr> CurlRestClient::Put( RestContext& context, RestRequest const& request, std::vector> const& payload) { diff --git a/google/cloud/internal/curl_rest_client.h b/google/cloud/internal/curl_rest_client.h index d5a3cfdb9500c..0d3cd8c1e696d 100644 --- a/google/cloud/internal/curl_rest_client.h +++ b/google/cloud/internal/curl_rest_client.h @@ -66,6 +66,9 @@ class CurlRestClient : public RestClient { RestContext& context, RestRequest const& request, std::vector> const& form_data) override; + // StatusOr> Post( + // RestContext& context, RestRequest const& request, + // nlohmann::json const& json_payload) override; StatusOr> Put( RestContext& context, RestRequest const& request, std::vector> const& payload) override; diff --git a/google/cloud/internal/make_jwt_assertion.cc b/google/cloud/internal/make_jwt_assertion.cc index eacea0bec5adc..4b2dc4ebd2910 100644 --- a/google/cloud/internal/make_jwt_assertion.cc +++ b/google/cloud/internal/make_jwt_assertion.cc @@ -23,10 +23,17 @@ namespace internal { StatusOr MakeJWTAssertionNoThrow(std::string const& header, std::string const& payload, - std::string const& pem_contents) { + std::string const& pem_contents, + JWTAlg alg) { auto const body = UrlsafeBase64Encode(header) + '.' + UrlsafeBase64Encode(payload); - auto pem_signature = internal::SignUsingSha256(body, pem_contents); + if (alg == JWTAlg::ES256) { + auto pem_signature = internal::SignUsingSha256(body, pem_contents, true); + if (!pem_signature) return std::move(pem_signature).status(); + return body + '.' + UrlsafeBase64Encode(*pem_signature); + } + + auto pem_signature = internal::SignUsingSha256(body, pem_contents, false); if (!pem_signature) return std::move(pem_signature).status(); return body + '.' + UrlsafeBase64Encode(*pem_signature); } diff --git a/google/cloud/internal/make_jwt_assertion.h b/google/cloud/internal/make_jwt_assertion.h index c515b906b65dc..5aefc89431b48 100644 --- a/google/cloud/internal/make_jwt_assertion.h +++ b/google/cloud/internal/make_jwt_assertion.h @@ -23,10 +23,13 @@ namespace google { namespace cloud { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace internal { + +enum class JWTAlg { RS256, ES256 }; + StatusOr MakeJWTAssertionNoThrow(std::string const& header, std::string const& payload, - std::string const& pem_contents); - + std::string const& pem_contents, + JWTAlg alg = JWTAlg::RS256); } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace cloud diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc index 0944b907bafc3..d3fc2192da52f 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -14,16 +14,13 @@ #include "google/cloud/internal/oauth2_gdch_service_account_credentials.h" #include "google/cloud/credentials.h" -#include "google/cloud/internal/getenv.h" #include "google/cloud/internal/make_jwt_assertion.h" #include "google/cloud/internal/make_status.h" #include "google/cloud/internal/oauth2_google_credentials.h" -#include "google/cloud/internal/oauth2_universe_domain.h" #include "google/cloud/internal/parse_service_account_p12_file.h" #include "google/cloud/internal/rest_response.h" -#include "google/cloud/internal/sign_using_sha256.h" #include "absl/strings/str_join.h" -#include "absl/strings/str_replace.h" +#include "absl/strings/str_split.h" #include #include #include @@ -38,6 +35,7 @@ using ::google::cloud::internal::MakeJWTAssertionNoThrow; StatusOr GDCHServiceAccountCredentials::Parse(std::string const& content, std::string const& source) { + std::cout << __func__ << std::endl; auto credentials = nlohmann::json::parse(content, nullptr, false); if (credentials.is_discarded()) { return internal::InvalidArgumentError(absl::StrCat( @@ -47,8 +45,10 @@ GDCHServiceAccountCredentials::Parse(std::string const& content, using Validator = std::function; - using Store = std::function; + using Store = std::function; + auto optional_field = [](absl::string_view, nlohmann::json::iterator const&) { + return Status{}; + }; auto non_empty_field = [&](absl::string_view name, nlohmann::json::iterator const& l) { if (l == credentials.end()) return Status{}; @@ -72,40 +72,39 @@ GDCHServiceAccountCredentials::Parse(std::string const& content, Validator validator; Store store; }; - std::vector fields{{"project_id", required_field, - [](GDCHServiceAccountCredentials::Info& info, - nlohmann::json::iterator const& l) { - info.project_id = l->get(); - }}, - {"private_key_id", required_field, - [&](GDCHServiceAccountCredentials::Info& info, - nlohmann::json::iterator const& l) { - if (l == credentials.end()) return; - info.private_key_id = l->get(); - }}, - {"private_key", required_field, - [](GDCHServiceAccountCredentials::Info& info, - nlohmann::json::iterator const& l) { - info.private_key = l->get(); - }}, - {"name", required_field, - [&](GDCHServiceAccountCredentials::Info& info, - nlohmann::json::iterator const& l) { - info.service_identity_name = - l->get(); - }}, - {"ca_cert_path", required_field, - [&](GDCHServiceAccountCredentials::Info& info, - nlohmann::json::iterator const& l) { - info.ca_cert_path = l->get(); - }}, - {"token_uri", required_field, - [&](GDCHServiceAccountCredentials::Info& info, - nlohmann::json::iterator const& l) { - info.token_uri = l->get(); - }}}; + std::vector fields{ + {"project", required_field, + [](Info& info, nlohmann::json::iterator const& l) { + info.project_id = l->get(); + }}, + {"private_key_id", required_field, + [&](Info& info, nlohmann::json::iterator const& l) { + if (l == credentials.end()) return; + info.private_key_id = l->get(); + }}, + {"private_key", required_field, + [](Info& info, nlohmann::json::iterator const& l) { + info.private_key = l->get(); + }}, + {"name", required_field, + [&](Info& info, nlohmann::json::iterator const& l) { + info.service_identity_name = l->get(); + }}, + {"ca_cert_path", optional_field, + [&](Info& info, nlohmann::json::iterator const& l) { + if (l == credentials.end()) return; + info.ca_cert_path = l->get(); + std::cout << __func__ << ": ca_cert_path=" << info.ca_cert_path + << std::endl; + }}, + {"token_uri", required_field, + [&](Info& info, nlohmann::json::iterator const& l) { + info.token_uri = l->get(); + }}}; + + std::cout << __func__ << ": start parsing" << std::endl; - auto info = GDCHServiceAccountCredentials::Info{}; + Info info; for (auto& f : fields) { auto l = credentials.find(f.name); if (l != credentials.end() && !l->is_string()) { @@ -115,22 +114,28 @@ GDCHServiceAccountCredentials::Parse(std::string const& content, source)); } auto status = f.validator(f.name, l); - if (!status.ok()) return status; + if (!status.ok()) { + std::cout << __func__ << ": parsing error" << std::endl; + return status; + } f.store(info, l); } + + std::cout << __func__ << ": successfully parsed" << std::endl; + return info; } std::pair GDCHServiceAccountCredentials::AssertionComponentsFromInfo( - GDCHServiceAccountCredentials::Info const& info, - std::chrono::system_clock::time_point now) { - nlohmann::json assertion_header = {{"alg", "RS256"}, {"typ", "JWT"}}; + Info const& info, std::chrono::system_clock::time_point now) { + nlohmann::json assertion_header = {{"alg", "ES256"}, {"typ", "JWT"}}; if (!info.private_key_id.empty()) { assertion_header["kid"] = info.private_key_id; } - auto expiration = now + GoogleOAuthAccessTokenLifetime(); + // auto expiration = now + GoogleOAuthAccessTokenLifetime(); + auto expiration = now + std::chrono::seconds(600); // As much as possible, do the time arithmetic using the std::chrono types. // Convert to an integer only when we are dealing with timestamps since the // epoch. Note that we cannot use `time_t` directly because that might be a @@ -156,20 +161,24 @@ GDCHServiceAccountCredentials::AssertionComponentsFromInfo( std::string GDCHServiceAccountCredentials::MakeJWTAssertion( std::string const& header, std::string const& payload, std::string const& pem_contents) { - return internal::MakeJWTAssertionNoThrow(header, payload, pem_contents) + return internal::MakeJWTAssertionNoThrow( + header, payload, pem_contents, + google::cloud::internal::JWTAlg::ES256) .value(); } -std::vector> -GDCHServiceAccountCredentials::CreateRefreshPayload( - GDCHServiceAccountCredentials::Info const& info, - std::chrono::system_clock::time_point now) { +nlohmann::json GDCHServiceAccountCredentials::CreateRefreshPayload( + Info const& info, std::chrono::system_clock::time_point now) { auto [header, payload] = AssertionComponentsFromInfo(info, now); - return { + std::cout << __func__ << ": header=" << header << std::endl; + std::cout << __func__ << ": payload=" << payload << std::endl; + auto jwt = MakeJWTAssertion(header, payload, info.private_key); + std::cout << __func__ << ": jwt=" << jwt << std::endl; + return nlohmann::json{ {"grant_type", "urn:ietf:params:oauth:token-type:token-exchange"}, {"audience", info.audience}, {"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"}, - {"subject_token", MakeJWTAssertion(header, payload, info.private_key)}, + {"subject_token", std::move(jwt)}, {"subject_token_type", "urn:k8s:params:oauth:token-type:serviceaccount"}}; } @@ -179,17 +188,18 @@ StatusOr GDCHServiceAccountCredentials::ParseRefreshResponse( auto status_code = response.StatusCode(); auto payload = rest_internal::ReadAll(std::move(response).ExtractPayload()); if (!payload.ok()) return std::move(payload).status(); + auto payload_copy = *payload; auto access_token = nlohmann::json::parse(*payload, nullptr, false); if (access_token.is_discarded() || access_token.count("access_token") == 0 || access_token.count("expires_in") == 0 || access_token.count("token_type") == 0 || access_token.count("issued_token_type") == 0) { auto error_payload = - *payload + + payload_copy + "Could not find all required fields in response (access_token," " expires_in, token_type, issued_token_type) while trying to obtain an" " access token for GDCH service account credentials."; - return AsStatus(status_code, error_payload); + return internal::InvalidArgumentError(error_payload, GCP_ERROR_INFO()); } auto expires_in = std::chrono::seconds(access_token.value("expires_in", 0)); return AccessToken{access_token.value("access_token", ""), now + expires_in}; @@ -228,6 +238,10 @@ StatusOr> GDCHServiceAccountCredentials::CreateFromFilePath( std::string const& path, Options const& options, HttpClientFactory client_factory) { + if (path.empty()) { + return internal::InvalidArgumentError( + "GOOGLE_APPLICATION_CREDENTIALS env var was empty.", GCP_ERROR_INFO()); + } std::ifstream is(path); if (!is.is_open()) { // We use kUnknown here because we don't know if the file does not exist, or @@ -252,12 +266,21 @@ GDCHServiceAccountCredentials::GDCHServiceAccountCredentials( StatusOr GDCHServiceAccountCredentials::GetToken( std::chrono::system_clock::time_point tp) { - auto client = client_factory_(options_); + Options options = options_; + if (!info_.ca_cert_path.empty()) { + std::cout << __func__ << ": set ca_cert_path=" << info_.ca_cert_path + << std::endl; + options.set(info_.ca_cert_path); + } else { + std::cout << __func__ << ": did NOT set ca_cert_path" << std::endl; + } + auto client = client_factory_(std::move(options)); rest_internal::RestRequest request; request.SetPath(info_.token_uri); + request.AddHeader("Content-Type", "application/json"); auto payload = CreateRefreshPayload(info_, tp); rest_internal::RestContext context; - auto response = client->Post(context, request, payload); + auto response = client->Post(context, request, {payload.dump()}); if (!response) return std::move(response).status(); if (IsHttpError(**response)) return AsStatus(std::move(**response)); return ParseRefreshResponse(**response, tp); diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.h b/google/cloud/internal/oauth2_gdch_service_account_credentials.h index c862ac67883d3..a8e079d1e5e2f 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.h +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -15,14 +15,16 @@ #ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_GDCH_SERVICE_ACCOUNT_CREDENTIALS_H #define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_OAUTH2_GDCH_SERVICE_ACCOUNT_CREDENTIALS_H -#include "google/cloud/internal/oauth2_credential_constants.h" #include "google/cloud/internal/oauth2_credentials.h" #include "google/cloud/internal/oauth2_http_client_factory.h" +#include "google/cloud/internal/rest_response.h" +#include "google/cloud/options.h" #include "google/cloud/status_or.h" #include "google/cloud/version.h" #include -#include +#include #include +#include #include namespace google { @@ -36,62 +38,17 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN * This class is not intended for use by application developers. But it is * sufficiently complex that it deserves documentation for library developers. * - * // TODO(sdhart): update this documentation * This class description assumes that you are familiar with [service accounts], * and [service account keys]. * - * Use `ParseGDCHServiceAccountCredentials()` to parse a service account key. If - * the key is parsed successfully, you can create an instance of this class - * using its result. The service account key is never sent to Google for - * authentication. Instead, this class creates temporary access tokens, either - * self-signed JWT (as described in [aip/4111]), or OAuth access tokens (see - * [aip/4112]). - * - * To understand how these work it is useful to be familiar with [JWTs]. If you - * already know what these, feel free to skip this paragraph. JWTs are - * (relatively long) strings consisting of three (base64-encoded) components. - * The first two are base64 encoded JSON objects. These fields in these objects - * are often referred as "claims". For example, the `iat` (Issued At-Time) - * field, asserts or claims that the token was created at a certain time. The - * third component in a JWT is a signature created using some secret. In our - * case the signature is always created using the [RS256] signing algorithm. - * One of the claims is always the - * identifier for the service account key. Google Cloud has the public key - * associated with each service account key and can use this to verify that the - * JWT was actually signed by the service account key claimed by the JWT. - * - * With self-signed JWT, the token is created locally, the payload contains - * either an audience (`"aud"`) or scope (`"scope"`) claim (but not both) - * describing the service or services that the token grants access to. Setting - * a more restrictive scope or audience allows applications to create tokens - * that restrict the access for a service account. This class **only** supports - * scope-based self-signed JWTs. - * - * With OAuth-based access tokens the client library creates a JWT and makes a - * HTTP request to convert this JWT into an access token. In general, - * self-signed JWTs are preferred over OAuth-based access tokens. On the other - * hand, our implementation of OAuth-based access tokens has more flight hours, - * and has been tested in more environments (on-prem, VPC-SC with different - * restrictions, etc.). - * - * Since access tokens are relatively expensive to create this class caches the - * access tokens until they are about to expire. Use the - * `AuthenticationHeader()` to get the current access token. - * - * [aip/4111]: https://google.aip.dev/auth/4111 - * [aip/4112]: https://google.aip.dev/auth/4112 - * [RS256]: https://datatracker.ietf.org/doc/html/rfc7518 - * [JWTs]: https://en.wikipedia.org/wiki/JSON_Web_Token - * [service accounts]: - * https://cloud.google.com/iam/docs/overview#service_account - * - * [iam-overview]: - * https://cloud.google.com/iam/docs/overview - * - * [service account keys]: - * https://cloud.google.com/iam/docs/creating-managing-service-account-keys#iam-service-account-keys-create-cpp + * The various `CreqteFrom*` methods parse the contents of the JSON key file. If + * the key is parsed successfully, an instance of this class is created. The + * service account key is never sent. Instead, this class creates a self-signed + * JWT from the contents of the JSON key file and uses it as the subject_token + * to perform an exchange via the token_uri found in the JSON key file to get an + * access_token. */ -class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { +class GDCHServiceAccountCredentials : public Credentials { public: /// Object to hold information used to instantiate an /// GDCHServiceAccountCredentials. @@ -113,23 +70,17 @@ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { static StatusOr Parse(std::string const& content, std::string const& source); - /** - * Creates a GDCHServiceAccountCredentials from a - * GDCHServiceAccountCredentialsInfo. - */ + /// Creates a GDCHServiceAccountCredentials from a + /// GDCHServiceAccountCredentialsInfo. static StatusOr> CreateFromInfo( Info info, Options const& options, HttpClientFactory client_factory); - /** - * Creates a GDCHServiceAccountCredentials from a JSON string. - */ + /// Creates a GDCHServiceAccountCredentials from a JSON string. static StatusOr> CreateFromJsonContents( std::string const& contents, Options const& options, HttpClientFactory client_factory); - /** - * Creates a GDCHServiceAccountCredentials from a file at the specified path. - */ + /// Creates a GDCHServiceAccountCredentials from a file at the specified path. static StatusOr> CreateFromFilePath( std::string const& path, Options const& options, HttpClientFactory client_factory); @@ -139,31 +90,24 @@ class GDCHServiceAccountCredentials : public oauth2_internal::Credentials { rest_internal::RestResponse& response, std::chrono::system_clock::time_point now); - /** - * Splits a GDCHServiceAccountCredentialsInfo into header and payload - * components and uses the current time to make a JWT assertion. - * - * @see - * https://cloud.google.com/endpoints/docs/frameworks/java/troubleshoot-jwt - * - * @see https://tools.ietf.org/html/rfc7523 - */ + /// Splits a GDCHServiceAccountCredentialsInfo into header and payload + /// components and uses the current time to make a JWT assertion. + /// + /// @see https://tools.ietf.org/html/rfc7523 static std::pair AssertionComponentsFromInfo( Info const& info, std::chrono::system_clock::time_point now); - /** - * Given a key and a JSON header and payload, creates a JWT assertion string. - * - * @see https://tools.ietf.org/html/rfc7519 - */ + /// Given a key and a JSON header and payload, creates a JWT assertion string. + /// + /// @see https://tools.ietf.org/html/rfc7519 static std::string MakeJWTAssertion(std::string const& header, std::string const& payload, std::string const& pem_contents); /// Uses a GDCHServiceAccountCredentialsInfo and the current time to construct - /// a JWT assertion. The assertion combined with the grant type is used to - /// create the refresh payload. - static std::vector> CreateRefreshPayload( + /// a JWT assertion. The assertion combined with the grant_type and audience + /// is used to create the refresh payload. + static nlohmann::json CreateRefreshPayload( Info const& info, std::chrono::system_clock::time_point now); StatusOr GetToken( diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 17943c85fb340..8550593900312 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -100,7 +100,7 @@ auto constexpr kTokenUri = "https://gdc.token.uri/v1/token"; nlohmann::json TestContents() { return nlohmann::json{ - {"project_id", kProjectId}, {"private_key_id", kPrivateKeyId}, + {"project", kProjectId}, {"private_key_id", kPrivateKeyId}, {"private_key", kPrivateKey}, {"name", kServiceIdentityName}, {"ca_cert_path", kCaCertPath}, {"token_uri", kTokenUri}, }; @@ -191,7 +191,7 @@ TEST(GDCHServiceAccountCredentialsTest, ParseInvalidJson) { /// @test Verify that parsing a service account JSON string works. TEST(GDCHServiceAccountCredentialsTest, ParseSimple) { std::string contents = R"""({ - "project_id": "test-project-id", + "project": "test-project-id", "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", "name": "test-service-identity", @@ -224,7 +224,7 @@ TEST(GDCHServiceAccountCredentialsTest, ParseInvalidContentsFails) { /// @test Parsing a service account JSON string should detect empty fields. TEST(GDCHServiceAccountCredentialsTest, ParseEmptyFieldFails) { std::string contents = R"""({ - "project_id": "test-project-id", + "project": "test-project-id", "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", "name": "test-service-identity", @@ -232,8 +232,8 @@ TEST(GDCHServiceAccountCredentialsTest, ParseEmptyFieldFails) { "token_uri": "https://gdc.token.uri/v1/token" })"""; - for (auto const& field : {"project_id", "private_key_id", "private_key", - "name", "ca_cert_path", "token_uri"}) { + for (auto const& field : + {"project", "private_key_id", "private_key", "name", "token_uri"}) { auto json = nlohmann::json::parse(contents); json[field] = ""; auto actual = @@ -248,7 +248,7 @@ TEST(GDCHServiceAccountCredentialsTest, ParseEmptyFieldFails) { /// @test Parsing a service account JSON string should detect invalid fields. TEST(GDCHServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { std::string contents = R"""({ - "project_id": "test-project-id", + "project": "test-project-id", "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", "name": "test-service-identity", @@ -256,8 +256,8 @@ TEST(GDCHServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { "token_uri": "https://gdc.token.uri/v1/token" })"""; - for (auto const& field : {"project_id", "private_key_id", "private_key", - "name", "ca_cert_path", "token_uri"}) { + for (auto const& field : {"project", "private_key_id", "private_key", "name", + "ca_cert_path", "token_uri"}) { auto json = nlohmann::json::parse(contents); json[field] = true; auto actual = @@ -274,16 +274,15 @@ TEST(GDCHServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { /// @test Parsing a service account JSON string should detect missing fields. TEST(GDCHServiceAccountCredentialsTest, ParseMissingFieldFails) { std::string contents = R"""({ - "project_id": "test-project-id", + "project": "test-project-id", "private_key_id": "not-a-key-id-just-for-testing", "private_key": "not-a-valid-key-just-for-testing", "name": "test-service-identity", - "ca_cert_path": "/test/ca.crt", "token_uri": "https://gdc.token.uri/v1/token" })"""; - for (auto const& field : {"project_id", "private_key_id", "private_key", - "name", "ca_cert_path", "token_uri"}) { + for (auto const& field : + {"project", "private_key_id", "private_key", "name", "token_uri"}) { auto json = nlohmann::json::parse(contents); json.erase(field); auto actual = diff --git a/google/cloud/internal/oauth2_service_account_credentials.cc b/google/cloud/internal/oauth2_service_account_credentials.cc index 9bfb7281eaf69..8da9a3d550a6f 100644 --- a/google/cloud/internal/oauth2_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_service_account_credentials.cc @@ -217,7 +217,8 @@ StatusOr ParseServiceAccountRefreshResponse( "Could not find all required fields in response (access_token," " expires_in, token_type) while trying to obtain an access token for" " service account credentials."; - return AsStatus(status_code, error_payload); + return internal::InternalError(error_payload, GCP_ERROR_INFO()); + // return AsStatus(status_code, error_payload); } auto expires_in = std::chrono::seconds(access_token.value("expires_in", 0)); diff --git a/google/cloud/internal/openssl/sign_using_sha256.cc b/google/cloud/internal/openssl/sign_using_sha256.cc index 3e19700d1d573..e7c74b84c4663 100644 --- a/google/cloud/internal/openssl/sign_using_sha256.cc +++ b/google/cloud/internal/openssl/sign_using_sha256.cc @@ -17,7 +17,9 @@ #include "google/cloud/internal/base64_transforms.h" #include "google/cloud/internal/make_status.h" #include +#include #include +#include #include #include #include @@ -74,10 +76,68 @@ std::string CaptureSslErrors() { return msg; } +bool der_to_raw_ecdsa_signature_openssl(unsigned char const* der_sig, + size_t der_len, int coord_size, + std::vector& raw_sig) { + if (!der_sig || der_len == 0) { + std::cerr << "Error: Input DER signature is empty." << std::endl; + return false; + } + + // d2i_ECDSA_SIG modifies the input pointer, so make a copy + unsigned char const* p = der_sig; + ECDSA_SIG* ecdsa_sig = d2i_ECDSA_SIG(NULL, &p, der_len); + + if (!ecdsa_sig) { + char err_buf[256]; + ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); + std::cerr << "Error parsing DER signature: " << err_buf << std::endl; + return false; + } + + const BIGNUM *r, *s; + ECDSA_SIG_get0(ecdsa_sig, &r, &s); + + if (!r || !s) { + std::cerr << "Error: Could not get r or s from ECDSA_SIG." << std::endl; + ECDSA_SIG_free(ecdsa_sig); + return false; + } + + raw_sig.resize(2 * coord_size); + unsigned char* raw_sig_ptr = raw_sig.data(); + + // Convert r to binary, padded to coord_size + int r_len = BN_bn2binpad(r, &raw_sig_ptr[0], coord_size); + if (r_len != coord_size) { + char err_buf[256]; + ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); + std::cerr << "Error converting r to binary (expected " << coord_size + << " bytes, got " << r_len << "): " << err_buf << std::endl; + ECDSA_SIG_free(ecdsa_sig); + return false; + } + + // Convert s to binary, padded to coord_size + int s_len = BN_bn2binpad(s, &raw_sig_ptr[coord_size], coord_size); + if (s_len != coord_size) { + char err_buf[256]; + ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); + std::cerr << "Error converting s to binary (expected " << coord_size + << " bytes, got " << s_len << "): " << err_buf << std::endl; + ECDSA_SIG_free(ecdsa_sig); + return false; + } + + ECDSA_SIG_free(ecdsa_sig); + return true; +} + } // namespace StatusOr> SignUsingSha256( - std::string const& str, std::string const& pem_contents) { + std::string const& str, std::string const& pem_contents, + bool convert_der_to_raw) { ERR_clear_error(); auto pem_buffer = std::unique_ptr(BIO_new_mem_buf( pem_contents.data(), static_cast(pem_contents.length()))); @@ -155,8 +215,16 @@ StatusOr> SignUsingSha256( GCP_ERROR_INFO()); } - return StatusOr>( - {buffer.begin(), std::next(buffer.begin(), actual_len)}); + std::vector sig{buffer.begin(), + std::next(buffer.begin(), actual_len)}; + std::vector raw_sig; + if (convert_der_to_raw) { + std::cout << __func__ << ": convert der to raw" << std::endl; + der_to_raw_ecdsa_signature_openssl(sig.data(), sig.size(), 32, raw_sig); + return StatusOr>(raw_sig); + } + std::cout << __func__ << ": DO NOT convert der to raw" << std::endl; + return StatusOr>(sig); } } // namespace internal diff --git a/google/cloud/internal/rest_client.h b/google/cloud/internal/rest_client.h index d554d4c6ea87c..4e286631864f5 100644 --- a/google/cloud/internal/rest_client.h +++ b/google/cloud/internal/rest_client.h @@ -23,6 +23,7 @@ #include "google/cloud/rest_options.h" #include "google/cloud/status_or.h" #include "google/cloud/version.h" +#include #include #include #include @@ -85,6 +86,9 @@ class RestClient { virtual StatusOr> Post( RestContext& context, RestRequest const& request, std::vector> const& form_data) = 0; + // virtual StatusOr> Post( + // RestContext& context, RestRequest const& request, + // nlohmann::json const& json_payload) = 0; virtual StatusOr> Put( RestContext& context, RestRequest const& request, std::vector> const& payload) = 0; diff --git a/google/cloud/internal/sign_using_sha256.h b/google/cloud/internal/sign_using_sha256.h index b6975d37b8d31..fdcb4dabd9a3c 100644 --- a/google/cloud/internal/sign_using_sha256.h +++ b/google/cloud/internal/sign_using_sha256.h @@ -34,7 +34,11 @@ namespace internal { * array to a format more suitable for transmission over HTTP. */ StatusOr> SignUsingSha256( - std::string const& str, std::string const& pem_contents); + std::string const& str, std::string const& pem_contents, + bool convert_der_to_raw = false); + +// StatusOr> ConvertSignatureFromDERToRaw( +// std::vector der_signature); } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/internal/unified_grpc_credentials.cc b/google/cloud/internal/unified_grpc_credentials.cc index 45851cce620a7..10eef846dbe75 100644 --- a/google/cloud/internal/unified_grpc_credentials.cc +++ b/google/cloud/internal/unified_grpc_credentials.cc @@ -153,6 +153,16 @@ std::shared_ptr CreateAuthenticationStrategy( void visit(ComputeEngineCredentialsConfig const&) override { result = std::make_unique(options); } + void visit(GDCHServiceAccountConfig const&) override { + result = std::make_unique( + ErrorCredentialsConfig{ + UnimplementedError("GDCHServiceAccountCredentials are not yet " + "supported for gRPC endpoints", + GCP_ERROR_INFO())}); + // if file path is specified, read json from it, handle errors + // else use json from cfg + // create + } } visitor(std::move(cq), std::move(options)); diff --git a/google/cloud/internal/unified_rest_credentials.cc b/google/cloud/internal/unified_rest_credentials.cc index 596617c57d7d9..c7cf3a1e32f51 100644 --- a/google/cloud/internal/unified_rest_credentials.cc +++ b/google/cloud/internal/unified_rest_credentials.cc @@ -23,6 +23,7 @@ #include "google/cloud/internal/oauth2_decorate_credentials.h" #include "google/cloud/internal/oauth2_error_credentials.h" #include "google/cloud/internal/oauth2_external_account_credentials.h" +#include "google/cloud/internal/oauth2_gdch_service_account_credentials.h" #include "google/cloud/internal/oauth2_google_credentials.h" #include "google/cloud/internal/oauth2_impersonate_service_account_credentials.h" #include "google/cloud/internal/oauth2_service_account_credentials.h" @@ -39,6 +40,7 @@ using ::google::cloud::internal::ComputeEngineCredentialsConfig; using ::google::cloud::internal::CredentialsVisitor; using ::google::cloud::internal::ErrorCredentialsConfig; using ::google::cloud::internal::ExternalAccountConfig; +using ::google::cloud::internal::GDCHServiceAccountConfig; using ::google::cloud::internal::GoogleDefaultCredentialsConfig; using ::google::cloud::internal::ImpersonateServiceAccountConfig; using ::google::cloud::internal::InsecureCredentialsConfig; @@ -152,6 +154,31 @@ std::shared_ptr MapCredentials( result = Decorate(std::move(creds), std::move(client_factory_), cfg.options()); } + void visit(GDCHServiceAccountConfig const& cfg) override { + Options audience_option; + // TODO(sdhart): reconsider AudienceOption and require users to explicitly + // call `MakeGDCHServiceAccountCredentials`. + if (!cfg.audience().empty()) { + audience_option.set(cfg.audience()); + } + auto options = internal::MergeOptions(cfg.options(), audience_option); + + StatusOr> creds; + if (cfg.file_path().has_value()) { + creds = + oauth2_internal::GDCHServiceAccountCredentials::CreateFromFilePath( + *cfg.file_path(), options, client_factory_); + } else { + creds = oauth2_internal::GDCHServiceAccountCredentials:: + CreateFromJsonContents(cfg.json_object(), options, client_factory_); + } + if (!creds.ok()) { + result = MakeErrorCredentials(std::move(creds).status()); + } else { + result = Decorate(*std::move(creds), std::move(client_factory_), + std::move(options)); + } + } private: oauth2_internal::HttpClientFactory client_factory_; diff --git a/google/cloud/internal/unified_rest_credentials_integration_test.cc b/google/cloud/internal/unified_rest_credentials_integration_test.cc index 4ac34a6dbc8cf..a8c954089d310 100644 --- a/google/cloud/internal/unified_rest_credentials_integration_test.cc +++ b/google/cloud/internal/unified_rest_credentials_integration_test.cc @@ -221,6 +221,26 @@ TEST(UnifiedRestCredentialsIntegrationTest, StorageSelfSignedJWT) { MakeServiceAccountCredentials(contents)))); } +MATCHER(AccessTokenIsSTSBearer, "access token is STS Bearer") { + return absl::StartsWith(arg.token, "STS-Bearer-"); +} + +TEST(UnifiedRestCredentialsIntegrationTest, GDCHServiceAccountCredentials) { + auto key_file_env = + internal::GetEnv("GOOGLE_CLOUD_CPP_REST_TEST_GDCH_KEY_FILE"); + if (!key_file_env.has_value()) GTEST_SKIP(); + std::ifstream is(*key_file_env); + auto contents = std::string{std::istreambuf_iterator{is}, {}}; + ASSERT_THAT(contents, testing::Not(testing::IsEmpty())); + + std::string const kAudience = "global-api"; + auto gdch_creds = MakeGDCHServiceAccountCredentials(contents, kAudience); + EXPECT_THAT(gdch_creds, testing::NotNull()); + auto oauth2_creds = MapCredentials(*gdch_creds); + EXPECT_THAT(oauth2_creds->GetToken(std::chrono::system_clock::now()), + testing_util::IsOkAndHolds(AccessTokenIsSTSBearer())); +} + } // namespace GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace rest_internal diff --git a/google/cloud/internal/unified_rest_credentials_test.cc b/google/cloud/internal/unified_rest_credentials_test.cc index ab66fa764858d..e41f40a1c554f 100644 --- a/google/cloud/internal/unified_rest_credentials_test.cc +++ b/google/cloud/internal/unified_rest_credentials_test.cc @@ -125,10 +125,12 @@ std::unique_ptr MakeMockResponse(std::string contents) { return response; } +auto constexpr kProjectId = "invalid-test-only-project"; + nlohmann::json MakeServiceAccountContents() { return nlohmann::json{ {"type", "service_account"}, - {"project_id", "invalid-test-only-project"}, + {"project_id", kProjectId}, {"private_key_id", kServiceAccountKeyId}, {"private_key", kWellFormattedKey}, {"client_email", kServiceAccountEmail}, @@ -143,6 +145,19 @@ nlohmann::json MakeServiceAccountContents() { }; } +auto constexpr kCaCertPath = "/test/ca.crt"; +auto constexpr kTokenUri = "https://gdc.token.uri/v1/token"; +nlohmann::json MakeGDCHServiceAccountContents() { + return nlohmann::json{ + {"project_id", kProjectId}, + {"private_key_id", kServiceAccountKeyId}, + {"private_key", kWellFormattedKey}, + {"name", kServiceAccountEmail}, + {"ca_cert_path", kCaCertPath}, + {"token_uri", kTokenUri}, + }; +} + ScopedEnvironment SetUpAdcFile(std::string const& filename, std::string const& contents) { std::ofstream(filename) << contents; @@ -500,6 +515,112 @@ TEST(UnifiedRestCredentialsTest, ApiKey) { IsOkAndHolds(Contains(HttpHeader("x-goog-api-key", "api-key")))); } +TEST(UnifiedRestCredentialsTest, MakeGDCHServiceAccountNoAudience) { + auto creds = MakeGDCHServiceAccountCredentials( + MakeGDCHServiceAccountContents().dump(), {}, {}); + ASSERT_THAT(creds, NotNull()); + + auto oauth2_creds = MapCredentials(*creds); + ASSERT_THAT(oauth2_creds, NotNull()); + + auto header = + oauth2_creds->AuthenticationHeaders(std::chrono::system_clock::now(), ""); + EXPECT_THAT(header, + StatusIs(StatusCode::kInvalidArgument, HasSubstr("Audience"))); +} + +TEST(UnifiedRestCredentialsTest, MakeGDCHServiceAccountUsesAudienceParameter) { + auto constexpr kAudience = "test-audience"; + auto const post_response = std::string{R"""({ + "access_token":"access-token-value", + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3599 + })"""}; + + auto token_client = [=] { + using FormDataType = std::vector>; + auto mock = std::make_unique(); + auto expected_request = Property(&RestRequest::path, kTokenUri); + auto expected_form_data = + MatcherCast(Contains(Pair("audience", kAudience))); + EXPECT_CALL(*mock, Post(_, expected_request, expected_form_data)) + .WillOnce([post_response]() { + auto response = std::make_unique(); + EXPECT_CALL(*response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*response), ExtractPayload) + .WillOnce( + Return(ByMove(MakeMockHttpPayloadSuccess(post_response)))); + return std::unique_ptr( + std::move(response)); + }); + return mock; + }(); + + MockClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call) + .WillOnce(Return(ByMove(std::move(token_client)))); + + auto creds = MakeGDCHServiceAccountCredentials( + MakeGDCHServiceAccountContents().dump(), kAudience, {}); + ASSERT_THAT(creds, NotNull()); + + auto oauth2_creds = + MapCredentials(*creds, mock_client_factory.AsStdFunction()); + ASSERT_THAT(oauth2_creds, NotNull()); + + auto header = + oauth2_creds->AuthenticationHeaders(std::chrono::system_clock::now(), ""); +} + +TEST(UnifiedRestCredentialsTest, MakeGDCHServiceAccountUsesAudienceOption) { + auto constexpr kAudience = "test-audience"; + auto const post_response = std::string{R"""({ + "access_token":"access-token-value", + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3599 + })"""}; + + auto token_client = [=] { + using FormDataType = std::vector>; + auto mock = std::make_unique(); + auto expected_request = Property(&RestRequest::path, kTokenUri); + auto expected_form_data = + MatcherCast(Contains(Pair("audience", kAudience))); + EXPECT_CALL(*mock, Post(_, expected_request, expected_form_data)) + .WillOnce([post_response]() { + auto response = std::make_unique(); + EXPECT_CALL(*response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*response), ExtractPayload) + .WillOnce( + Return(ByMove(MakeMockHttpPayloadSuccess(post_response)))); + return std::unique_ptr( + std::move(response)); + }); + return mock; + }(); + + MockClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call) + .WillOnce(Return(ByMove(std::move(token_client)))); + + auto creds = MakeGDCHServiceAccountCredentials( + MakeGDCHServiceAccountContents().dump(), "audience-arg-not-used", + Options{}.set(kAudience)); + + ASSERT_THAT(creds, NotNull()); + + auto oauth2_creds = + MapCredentials(*creds, mock_client_factory.AsStdFunction()); + ASSERT_THAT(oauth2_creds, NotNull()); + + auto header = + oauth2_creds->AuthenticationHeaders(std::chrono::system_clock::now(), ""); +} + TEST(UnifiedRestCredentialsTest, LoadError) { // Create a name for a non-existing file, try to load it, and verify it // returns errors. diff --git a/google/cloud/testing_util/credentials.h b/google/cloud/testing_util/credentials.h index 1aca048ccb02f..cf71287631f64 100644 --- a/google/cloud/testing_util/credentials.h +++ b/google/cloud/testing_util/credentials.h @@ -31,6 +31,7 @@ struct TestCredentialsVisitor : public internal::CredentialsVisitor { internal::ImpersonateServiceAccountConfig const* impersonate = nullptr; std::string json_object; std::string api_key; + std::string audience; Options options; void visit(internal::ErrorCredentialsConfig const&) override { @@ -72,6 +73,12 @@ struct TestCredentialsVisitor : public internal::CredentialsVisitor { name = "ComputeEngineCredentialsConfig"; options = cfg.options(); } + void visit(internal::GDCHServiceAccountConfig const& cfg) override { + name = "GDCHServiceAccountConfig"; + json_object = cfg.json_object(); + audience = cfg.audience(); + options = cfg.options(); + } }; } // namespace testing_util diff --git a/google/cloud/testing_util/mock_rest_client.h b/google/cloud/testing_util/mock_rest_client.h index 56dc309898368..60ea020a086d3 100644 --- a/google/cloud/testing_util/mock_rest_client.h +++ b/google/cloud/testing_util/mock_rest_client.h @@ -45,6 +45,11 @@ class MockRestClient : public rest_internal::RestClient { rest_internal::RestRequest const& request, (std::vector> const&)), (override)); + // MOCK_METHOD(StatusOr>, Post, + // (rest_internal::RestContext&, + // rest_internal::RestRequest const& request, + // (nlohmann::json const&)), + // (override)); MOCK_METHOD(StatusOr>, Put, (rest_internal::RestContext&, rest_internal::RestRequest const&, std::vector> const&), From a761535532727c7cfac0fffe919d6663f74e8f85 Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Thu, 21 May 2026 19:46:08 -0400 Subject: [PATCH 7/9] mostly updated unit tests --- google/cloud/internal/make_jwt_assertion.cc | 10 +- google/cloud/internal/make_jwt_assertion.h | 17 +-- ...oauth2_gdch_service_account_credentials.cc | 41 ++----- .../oauth2_gdch_service_account_credentials.h | 8 +- ...2_gdch_service_account_credentials_test.cc | 100 +++++++----------- .../internal/openssl/sign_using_sha256.cc | 55 +++++----- google/cloud/internal/sign_using_sha256.h | 11 +- ...ified_rest_credentials_integration_test.cc | 14 ++- 8 files changed, 112 insertions(+), 144 deletions(-) diff --git a/google/cloud/internal/make_jwt_assertion.cc b/google/cloud/internal/make_jwt_assertion.cc index 4b2dc4ebd2910..2f1224e8ee5ba 100644 --- a/google/cloud/internal/make_jwt_assertion.cc +++ b/google/cloud/internal/make_jwt_assertion.cc @@ -24,16 +24,10 @@ namespace internal { StatusOr MakeJWTAssertionNoThrow(std::string const& header, std::string const& payload, std::string const& pem_contents, - JWTAlg alg) { + SignatureFormat format) { auto const body = UrlsafeBase64Encode(header) + '.' + UrlsafeBase64Encode(payload); - if (alg == JWTAlg::ES256) { - auto pem_signature = internal::SignUsingSha256(body, pem_contents, true); - if (!pem_signature) return std::move(pem_signature).status(); - return body + '.' + UrlsafeBase64Encode(*pem_signature); - } - - auto pem_signature = internal::SignUsingSha256(body, pem_contents, false); + auto pem_signature = internal::SignUsingSha256(body, pem_contents, format); if (!pem_signature) return std::move(pem_signature).status(); return body + '.' + UrlsafeBase64Encode(*pem_signature); } diff --git a/google/cloud/internal/make_jwt_assertion.h b/google/cloud/internal/make_jwt_assertion.h index 5aefc89431b48..e28a226337774 100644 --- a/google/cloud/internal/make_jwt_assertion.h +++ b/google/cloud/internal/make_jwt_assertion.h @@ -15,6 +15,7 @@ #ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_MAKE_JWT_ASSERTION_H #define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_INTERNAL_MAKE_JWT_ASSERTION_H +#include "google/cloud/internal/sign_using_sha256.h" #include "google/cloud/status_or.h" #include "google/cloud/version.h" #include @@ -24,12 +25,16 @@ namespace cloud { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace internal { -enum class JWTAlg { RS256, ES256 }; - -StatusOr MakeJWTAssertionNoThrow(std::string const& header, - std::string const& payload, - std::string const& pem_contents, - JWTAlg alg = JWTAlg::RS256); +/** + * Creates a JWT. + * + * @note SignatureFormat defaults to SignatureFormat::kDER for backwards + * compatibility. + */ +StatusOr MakeJWTAssertionNoThrow( + std::string const& header, std::string const& payload, + std::string const& pem_contents, + SignatureFormat format = SignatureFormat::kDER); } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace cloud diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc index d3fc2192da52f..73b662a59fa97 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -35,7 +35,6 @@ using ::google::cloud::internal::MakeJWTAssertionNoThrow; StatusOr GDCHServiceAccountCredentials::Parse(std::string const& content, std::string const& source) { - std::cout << __func__ << std::endl; auto credentials = nlohmann::json::parse(content, nullptr, false); if (credentials.is_discarded()) { return internal::InvalidArgumentError(absl::StrCat( @@ -94,16 +93,12 @@ GDCHServiceAccountCredentials::Parse(std::string const& content, [&](Info& info, nlohmann::json::iterator const& l) { if (l == credentials.end()) return; info.ca_cert_path = l->get(); - std::cout << __func__ << ": ca_cert_path=" << info.ca_cert_path - << std::endl; }}, {"token_uri", required_field, [&](Info& info, nlohmann::json::iterator const& l) { info.token_uri = l->get(); }}}; - std::cout << __func__ << ": start parsing" << std::endl; - Info info; for (auto& f : fields) { auto l = credentials.find(f.name); @@ -114,15 +109,9 @@ GDCHServiceAccountCredentials::Parse(std::string const& content, source)); } auto status = f.validator(f.name, l); - if (!status.ok()) { - std::cout << __func__ << ": parsing error" << std::endl; - return status; - } + if (!status.ok()) return status; f.store(info, l); } - - std::cout << __func__ << ": successfully parsed" << std::endl; - return info; } @@ -134,8 +123,7 @@ GDCHServiceAccountCredentials::AssertionComponentsFromInfo( assertion_header["kid"] = info.private_key_id; } - // auto expiration = now + GoogleOAuthAccessTokenLifetime(); - auto expiration = now + std::chrono::seconds(600); + auto expiration = now + GoogleOAuthAccessTokenLifetime(); // As much as possible, do the time arithmetic using the std::chrono types. // Convert to an integer only when we are dealing with timestamps since the // epoch. Note that we cannot use `time_t` directly because that might be a @@ -158,27 +146,23 @@ GDCHServiceAccountCredentials::AssertionComponentsFromInfo( return std::make_pair(assertion_header.dump(), assertion_payload.dump()); } -std::string GDCHServiceAccountCredentials::MakeJWTAssertion( +StatusOr GDCHServiceAccountCredentials::MakeJWTAssertion( std::string const& header, std::string const& payload, std::string const& pem_contents) { - return internal::MakeJWTAssertionNoThrow( - header, payload, pem_contents, - google::cloud::internal::JWTAlg::ES256) - .value(); + return internal::MakeJWTAssertionNoThrow(header, payload, pem_contents, + internal::SignatureFormat::kRaw); } -nlohmann::json GDCHServiceAccountCredentials::CreateRefreshPayload( +StatusOr GDCHServiceAccountCredentials::CreateRefreshPayload( Info const& info, std::chrono::system_clock::time_point now) { auto [header, payload] = AssertionComponentsFromInfo(info, now); - std::cout << __func__ << ": header=" << header << std::endl; - std::cout << __func__ << ": payload=" << payload << std::endl; auto jwt = MakeJWTAssertion(header, payload, info.private_key); - std::cout << __func__ << ": jwt=" << jwt << std::endl; + if (!jwt) return std::move(jwt.status()); return nlohmann::json{ {"grant_type", "urn:ietf:params:oauth:token-type:token-exchange"}, {"audience", info.audience}, {"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"}, - {"subject_token", std::move(jwt)}, + {"subject_token", std::move(*jwt)}, {"subject_token_type", "urn:k8s:params:oauth:token-type:serviceaccount"}}; } @@ -217,7 +201,7 @@ GDCHServiceAccountCredentials::CreateFromInfo( // Verify this is usable before returning it. auto const tp = std::chrono::system_clock::time_point{}; auto const [header, payload] = AssertionComponentsFromInfo(info, tp); - auto jwt = MakeJWTAssertionNoThrow(header, payload, info.private_key); + auto jwt = MakeJWTAssertion(header, payload, info.private_key); if (!jwt) return jwt.status(); return StatusOr>( std::unique_ptr( @@ -268,19 +252,16 @@ StatusOr GDCHServiceAccountCredentials::GetToken( std::chrono::system_clock::time_point tp) { Options options = options_; if (!info_.ca_cert_path.empty()) { - std::cout << __func__ << ": set ca_cert_path=" << info_.ca_cert_path - << std::endl; options.set(info_.ca_cert_path); - } else { - std::cout << __func__ << ": did NOT set ca_cert_path" << std::endl; } auto client = client_factory_(std::move(options)); rest_internal::RestRequest request; request.SetPath(info_.token_uri); request.AddHeader("Content-Type", "application/json"); auto payload = CreateRefreshPayload(info_, tp); + if (!payload) return std::move(payload).status(); rest_internal::RestContext context; - auto response = client->Post(context, request, {payload.dump()}); + auto response = client->Post(context, request, {payload->dump()}); if (!response) return std::move(response).status(); if (IsHttpError(**response)) return AsStatus(std::move(**response)); return ParseRefreshResponse(**response, tp); diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials.h b/google/cloud/internal/oauth2_gdch_service_account_credentials.h index a8e079d1e5e2f..4b56b0388ac93 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials.h +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -100,14 +100,14 @@ class GDCHServiceAccountCredentials : public Credentials { /// Given a key and a JSON header and payload, creates a JWT assertion string. /// /// @see https://tools.ietf.org/html/rfc7519 - static std::string MakeJWTAssertion(std::string const& header, - std::string const& payload, - std::string const& pem_contents); + static StatusOr MakeJWTAssertion( + std::string const& header, std::string const& payload, + std::string const& pem_contents); /// Uses a GDCHServiceAccountCredentialsInfo and the current time to construct /// a JWT assertion. The assertion combined with the grant_type and audience /// is used to create the refresh payload. - static nlohmann::json CreateRefreshPayload( + static StatusOr CreateRefreshPayload( Info const& info, std::chrono::system_clock::time_point now); StatusOr GetToken( diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 8550593900312..5f280f8dbcdd1 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -65,34 +65,11 @@ auto constexpr kProjectId = "test-project-id"; auto constexpr kPrivateKeyId = "a1a111aa1111a11a11a11aa111a111a1a1111111"; // This is an invalidated private key. It was created using the Google Cloud // Platform console, but then the key (and service account) were deleted. -auto constexpr kPrivateKey = R"""(-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCltiF2oP3KJJ+S -tTc1McylY+TuAi3AdohX7mmqIjd8a3eBYDHs7FlnUrFC4CRijCr0rUqYfg2pmk4a -6TaKbQRAhWDJ7XD931g7EBvCtd8+JQBNWVKnP9ByJUaO0hWVniM50KTsWtyX3up/ -fS0W2R8Cyx4yvasE8QHH8gnNGtr94iiORDC7De2BwHi/iU8FxMVJAIyDLNfyk0hN -eheYKfIDBgJV2v6VaCOGWaZyEuD0FJ6wFeLybFBwibrLIBE5Y/StCrZoVZ5LocFP -T4o8kT7bU6yonudSCyNMedYmqHj/iF8B2UN1WrYx8zvoDqZk0nxIglmEYKn/6U7U -gyETGcW9AgMBAAECggEAC231vmkpwA7JG9UYbviVmSW79UecsLzsOAZnbtbn1VLT -Pg7sup7tprD/LXHoyIxK7S/jqINvPU65iuUhgCg3Rhz8+UiBhd0pCH/arlIdiPuD -2xHpX8RIxAq6pGCsoPJ0kwkHSw8UTnxPV8ZCPSRyHV71oQHQgSl/WjNhRi6PQroB -Sqc/pS1m09cTwyKQIopBBVayRzmI2BtBxyhQp9I8t5b7PYkEZDQlbdq0j5Xipoov -9EW0+Zvkh1FGNig8IJ9Wp+SZi3rd7KLpkyKPY7BK/g0nXBkDxn019cET0SdJOHQG -DiHiv4yTRsDCHZhtEbAMKZEpku4WxtQ+JjR31l8ueQKBgQDkO2oC8gi6vQDcx/CX -Z23x2ZUyar6i0BQ8eJFAEN+IiUapEeCVazuxJSt4RjYfwSa/p117jdZGEWD0GxMC -+iAXlc5LlrrWs4MWUc0AHTgXna28/vii3ltcsI0AjWMqaybhBTTNbMFa2/fV2OX2 -UimuFyBWbzVc3Zb9KAG4Y7OmJQKBgQC5324IjXPq5oH8UWZTdJPuO2cgRsvKmR/r -9zl4loRjkS7FiOMfzAgUiXfH9XCnvwXMqJpuMw2PEUjUT+OyWjJONEK4qGFJkbN5 -3ykc7p5V7iPPc7Zxj4mFvJ1xjkcj+i5LY8Me+gL5mGIrJ2j8hbuv7f+PWIauyjnp -Nx/0GVFRuQKBgGNT4D1L7LSokPmFIpYh811wHliE0Fa3TDdNGZnSPhaD9/aYyy78 -LkxYKuT7WY7UVvLN+gdNoVV5NsLGDa4cAV+CWPfYr5PFKGXMT/Wewcy1WOmJ5des -AgMC6zq0TdYmMBN6WpKUpEnQtbmh3eMnuvADLJWxbH3wCkg+4xDGg2bpAoGAYRNk -MGtQQzqoYNNSkfus1xuHPMA8508Z8O9pwKU795R3zQs1NAInpjI1sOVrNPD7Ymwc -W7mmNzZbxycCUL/yzg1VW4P1a6sBBYGbw1SMtWxun4ZbnuvMc2CTCh+43/1l+FHe -Mmt46kq/2rH2jwx5feTbOE6P6PINVNRJh/9BDWECgYEAsCWcH9D3cI/QDeLG1ao7 -rE2NcknP8N783edM07Z/zxWsIsXhBPY3gjHVz2LDl+QHgPWhGML62M0ja/6SsJW3 -YvLLIc82V7eqcVJTZtaFkuht68qu/Jn1ezbzJMJ4YXDYo1+KFi+2CAGR06QILb+I -lUtj+/nH3HDQjM4ltYfTPUg= ------END PRIVATE KEY----- +auto constexpr kPrivateKey = R"""(-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDGD4hNeIBG3lo4BKS1k4jpYhbnJSZwAuUwyK8wEiOP5oAoGCCqGSM49 +AwEHoUQDQgAEWK7gDAGAAzOfl6pHhpmvjbTeUPyclQk7+HgAWE6uGUtox/U8/sQQ +X3IM7YomoAWiNKWwBVskpXWj7L9dLkqhyQ== +-----END EC PRIVATE KEY----- )"""; auto constexpr kServiceIdentityName = "test-service-identity"; auto constexpr kCaCertPath = "/test/ca.crt"; @@ -119,7 +96,7 @@ TEST(GDCHServiceAccountCredentialsTest, })"""}; auto const expected_header = - nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + nlohmann::json{{"alg", "ES256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; auto const iat = static_cast(kFixedJwtTimestamp); auto const exp = iat + 3600; auto const iss_sub_value = absl::StrCat("system:serviceaccount:", kProjectId, @@ -132,19 +109,21 @@ TEST(GDCHServiceAccountCredentialsTest, expected_header.dump(), expected_payload.dump(), kPrivateKey); auto token_client = [=] { - using FormDataType = std::vector>; + using FormDataType = std::vector>; auto mock = std::make_unique(); auto expected_request = Property(&RestRequest::path, kTokenUri); - auto expected_form_data = MatcherCast(AllOf( - Contains(Pair("grant_type", - "urn:ietf:params:oauth:token-type:token-exchange")), - Contains(Pair("audience", kAudience)), - Contains(Pair("requested_token_type", - "urn:ietf:params:oauth:token-type:access_token")), - Contains(Pair("subject_token", assertion)), - Contains(Pair("subject_token_type", - "urn:k8s:params:oauth:token-type:serviceaccount")))); - EXPECT_CALL(*mock, Post(_, expected_request, expected_form_data)) + // auto expected_form_data = MatcherCast(AllOf( + // Contains(Pair("grant_type", + // "urn:ietf:params:oauth:token-type:token-exchange")), + // Contains(Pair("audience", kAudience)), + // Contains(Pair("requested_token_type", + // "urn:ietf:params:oauth:token-type:access_token")), + // Contains(Pair("subject_token", assertion)), + // Contains(Pair(absl::Span("subject_token_type"), + // absl::Span("urn:k8s:params:oauth:token-type:serviceaccount"))))); + EXPECT_CALL(*mock, + Post(_, expected_request, ::testing::A())) .WillOnce([post_response]() { auto response = std::make_unique(); EXPECT_CALL(*response, StatusCode) @@ -327,7 +306,7 @@ TEST(GDCHServiceAccountCredentialsTest, AssertionComponentsFromInfo) { GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, now); auto header = nlohmann::json::parse(components.first); - EXPECT_THAT(header.value("alg", ""), Eq("RS256")); + EXPECT_THAT(header.value("alg", ""), Eq("ES256")); EXPECT_THAT(header.value("typ", ""), Eq("JWT")); EXPECT_THAT(header.value("kid", ""), Eq(info->private_key_id)); @@ -354,25 +333,22 @@ TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, tp); auto assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( components.first, components.second, info->private_key); + ASSERT_STATUS_OK(assertion); - std::vector actual_tokens = absl::StrSplit(assertion, '.'); + std::vector actual_tokens = absl::StrSplit(*assertion, '.'); ASSERT_THAT(actual_tokens.size(), Eq(3)); std::vector> decoded(actual_tokens.size()); std::transform( actual_tokens.begin(), actual_tokens.end(), decoded.begin(), [](std::string const& e) { return UrlsafeBase64Decode(e).value(); }); - // Verify this is a valid key. - auto const signature = - SignUsingSha256(actual_tokens[0] + '.' + actual_tokens[1], kPrivateKey); - ASSERT_STATUS_OK(signature); - EXPECT_THAT(*signature, Eq(decoded[2])); - // Verify the header and payloads are valid. + // We cannot verify the signature in this same fashion as ECDSA relies on a + // random ephemeral key. auto const header = nlohmann::json::parse(decoded[0].begin(), decoded[0].end()); auto const expected_header = - nlohmann::json{{"alg", "RS256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + nlohmann::json{{"alg", "ES256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; EXPECT_THAT(header, Eq(expected_header)); auto const payload = nlohmann::json::parse(decoded[1]); @@ -400,21 +376,23 @@ TEST(GDCHServiceAccountCredentialsTest, GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, now); auto assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( components.first, components.second, info->private_key); + ASSERT_STATUS_OK(assertion); + auto actual_payload = GDCHServiceAccountCredentials::CreateRefreshPayload(*info, now); - EXPECT_THAT( - actual_payload, - Contains(Pair("grant_type", - "urn:ietf:params:oauth:token-type:token-exchange"))); - EXPECT_THAT(actual_payload, Contains(Pair("audience", kAudience))); - EXPECT_THAT(actual_payload, - Contains(Pair("requested_token_type", - "urn:ietf:params:oauth:token-type:access_token"))); - EXPECT_THAT(actual_payload, Contains(Pair("subject_token", assertion))); - EXPECT_THAT(actual_payload, - Contains(Pair("subject_token_type", - "urn:k8s:params:oauth:token-type:serviceaccount"))); + // EXPECT_THAT( + // actual_payload, + // IsOkAndHolds(Contains(Pair("grant_type", + // "urn:ietf:params:oauth:token-type:token-exchange")))); + // EXPECT_THAT(actual_payload, Contains(Pair("audience", kAudience))); + // EXPECT_THAT(actual_payload, + // Contains(Pair("requested_token_type", + // "urn:ietf:params:oauth:token-type:access_token"))); + // EXPECT_THAT(actual_payload, Contains(Pair("subject_token", assertion))); + // EXPECT_THAT(actual_payload, + // Contains(Pair("subject_token_type", + // "urn:k8s:params:oauth:token-type:serviceaccount"))); } /// @test Parsing a refresh response with missing fields results in failure. diff --git a/google/cloud/internal/openssl/sign_using_sha256.cc b/google/cloud/internal/openssl/sign_using_sha256.cc index e7c74b84c4663..acc3b1e21ceba 100644 --- a/google/cloud/internal/openssl/sign_using_sha256.cc +++ b/google/cloud/internal/openssl/sign_using_sha256.cc @@ -16,6 +16,7 @@ #include "google/cloud/internal/sign_using_sha256.h" #include "google/cloud/internal/base64_transforms.h" #include "google/cloud/internal/make_status.h" +#include "absl/strings/str_cat.h" #include #include #include @@ -76,12 +77,11 @@ std::string CaptureSslErrors() { return msg; } -bool der_to_raw_ecdsa_signature_openssl(unsigned char const* der_sig, - size_t der_len, int coord_size, - std::vector& raw_sig) { +Status DERToRawSignature(unsigned char const* der_sig, size_t der_len, + int coord_size, std::vector& raw_sig) { if (!der_sig || der_len == 0) { - std::cerr << "Error: Input DER signature is empty." << std::endl; - return false; + return internal::InvalidArgumentError("Input DER signature is empty.", + GCP_ERROR_INFO()); } // d2i_ECDSA_SIG modifies the input pointer, so make a copy @@ -91,17 +91,18 @@ bool der_to_raw_ecdsa_signature_openssl(unsigned char const* der_sig, if (!ecdsa_sig) { char err_buf[256]; ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); - std::cerr << "Error parsing DER signature: " << err_buf << std::endl; - return false; + return InvalidArgumentError( + absl::StrCat("Error parsing DER signature: ", err_buf), + GCP_ERROR_INFO()); } const BIGNUM *r, *s; ECDSA_SIG_get0(ecdsa_sig, &r, &s); if (!r || !s) { - std::cerr << "Error: Could not get r or s from ECDSA_SIG." << std::endl; + auto err_msg = "Error: Could not get r or s from ECDSA_SIG."; ECDSA_SIG_free(ecdsa_sig); - return false; + return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); } raw_sig.resize(2 * coord_size); @@ -112,10 +113,11 @@ bool der_to_raw_ecdsa_signature_openssl(unsigned char const* der_sig, if (r_len != coord_size) { char err_buf[256]; ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); - std::cerr << "Error converting r to binary (expected " << coord_size - << " bytes, got " << r_len << "): " << err_buf << std::endl; + auto err_msg = + absl::StrCat("Error converting r to binary (expected ", coord_size, + " bytes, got ", r_len, "): ", err_buf); ECDSA_SIG_free(ecdsa_sig); - return false; + return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); } // Convert s to binary, padded to coord_size @@ -123,21 +125,22 @@ bool der_to_raw_ecdsa_signature_openssl(unsigned char const* der_sig, if (s_len != coord_size) { char err_buf[256]; ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); - std::cerr << "Error converting s to binary (expected " << coord_size - << " bytes, got " << s_len << "): " << err_buf << std::endl; + auto err_msg = + absl::StrCat("Error converting s to binary (expected ", coord_size, + " bytes, got ", s_len, "): ", err_buf); ECDSA_SIG_free(ecdsa_sig); - return false; + return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); } ECDSA_SIG_free(ecdsa_sig); - return true; + return {}; } } // namespace StatusOr> SignUsingSha256( std::string const& str, std::string const& pem_contents, - bool convert_der_to_raw) { + SignatureFormat format) { ERR_clear_error(); auto pem_buffer = std::unique_ptr(BIO_new_mem_buf( pem_contents.data(), static_cast(pem_contents.length()))); @@ -215,16 +218,16 @@ StatusOr> SignUsingSha256( GCP_ERROR_INFO()); } - std::vector sig{buffer.begin(), - std::next(buffer.begin(), actual_len)}; - std::vector raw_sig; - if (convert_der_to_raw) { - std::cout << __func__ << ": convert der to raw" << std::endl; - der_to_raw_ecdsa_signature_openssl(sig.data(), sig.size(), 32, raw_sig); - return StatusOr>(raw_sig); + std::vector der_sig{buffer.begin(), + std::next(buffer.begin(), actual_len)}; + if (format == SignatureFormat::kDER) { + return der_sig; } - std::cout << __func__ << ": DO NOT convert der to raw" << std::endl; - return StatusOr>(sig); + + std::vector raw_sig; + auto status = DERToRawSignature(der_sig.data(), der_sig.size(), 32, raw_sig); + if (!status.ok()) return status; + return raw_sig; } } // namespace internal diff --git a/google/cloud/internal/sign_using_sha256.h b/google/cloud/internal/sign_using_sha256.h index fdcb4dabd9a3c..c5379c1e4a3c1 100644 --- a/google/cloud/internal/sign_using_sha256.h +++ b/google/cloud/internal/sign_using_sha256.h @@ -26,6 +26,12 @@ namespace cloud { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace internal { +/** + * OpenSSL outputs DER format signatures by default. RFC-7515 (JWT/JWS) + * specifies the Raw format should be used. + */ +enum class SignatureFormat { kDER, kRaw }; + /** * Signs a string with the private key from a PEM container. * @@ -35,10 +41,7 @@ namespace internal { */ StatusOr> SignUsingSha256( std::string const& str, std::string const& pem_contents, - bool convert_der_to_raw = false); - -// StatusOr> ConvertSignatureFromDERToRaw( -// std::vector der_signature); + SignatureFormat format = SignatureFormat::kDER); } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/internal/unified_rest_credentials_integration_test.cc b/google/cloud/internal/unified_rest_credentials_integration_test.cc index a8c954089d310..e17d4061c23ac 100644 --- a/google/cloud/internal/unified_rest_credentials_integration_test.cc +++ b/google/cloud/internal/unified_rest_credentials_integration_test.cc @@ -34,7 +34,11 @@ GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace { using ::google::cloud::testing_util::IsOk; +using ::google::cloud::testing_util::IsOkAndHolds; using ::google::cloud::testing_util::ScopedEnvironment; +using ::testing::IsEmpty; +using ::testing::Not; +using ::testing::NotNull; StatusOr> RetryRestRequest( std::function>()> const& request) { @@ -231,14 +235,14 @@ TEST(UnifiedRestCredentialsIntegrationTest, GDCHServiceAccountCredentials) { if (!key_file_env.has_value()) GTEST_SKIP(); std::ifstream is(*key_file_env); auto contents = std::string{std::istreambuf_iterator{is}, {}}; - ASSERT_THAT(contents, testing::Not(testing::IsEmpty())); + ASSERT_THAT(contents, Not(IsEmpty())); - std::string const kAudience = "global-api"; - auto gdch_creds = MakeGDCHServiceAccountCredentials(contents, kAudience); - EXPECT_THAT(gdch_creds, testing::NotNull()); + std::string const audience = "global-api"; + auto gdch_creds = MakeGDCHServiceAccountCredentials(contents, audience); + ASSERT_THAT(gdch_creds, NotNull()); auto oauth2_creds = MapCredentials(*gdch_creds); EXPECT_THAT(oauth2_creds->GetToken(std::chrono::system_clock::now()), - testing_util::IsOkAndHolds(AccessTokenIsSTSBearer())); + IsOkAndHolds(AccessTokenIsSTSBearer())); } } // namespace From b6cd6f820188cdd8c0145ea3cfeb3ed3b83b82aa Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Tue, 26 May 2026 16:44:02 -0400 Subject: [PATCH 8/9] UT updated --- google/cloud/internal/curl_rest_client.cc | 64 --------------- google/cloud/internal/curl_rest_client.h | 3 - ...2_gdch_service_account_credentials_test.cc | 79 ++++++++++++------- .../internal/openssl/sign_using_sha256.cc | 28 +++---- google/cloud/internal/rest_client.h | 3 - google/cloud/testing_util/mock_rest_client.h | 5 -- 6 files changed, 65 insertions(+), 117 deletions(-) diff --git a/google/cloud/internal/curl_rest_client.cc b/google/cloud/internal/curl_rest_client.cc index 8fa5fdbd272d9..c9489b0763c86 100644 --- a/google/cloud/internal/curl_rest_client.cc +++ b/google/cloud/internal/curl_rest_client.cc @@ -62,55 +62,13 @@ Status MakeRequestWithPayload( } std::size_t content_length = 0; - std::cout << __func__ << ": payload=" << std::endl; for (auto const& p : payload) { - std::cout << __func__ << std::string(p.begin(), p.end()) << std::endl; content_length += p.size(); } impl.SetHeader(HttpHeader("Content-Length", std::to_string(content_length))); return impl.MakeRequest(http_method, context, payload); } -#if 0 - -Status MakeRequestWithPayload( - CurlImpl::HttpMethod http_method, RestContext& context, - RestRequest const&, CurlImpl& impl, - nlohmann::json const& json_payload) { - - impl.SetHeader(HttpHeader("content-type", "application/json")); - impl.SetHeader(HttpHeader("content-length", std::to_string(json_payload.size()))); - return impl.MakeRequest(http_method, context, json_payload); - - // If no Content-Type is specified for the payload, default to - // application/x-www-form-urlencoded and encode the payload accordingly before - // making the request. - auto content_type = request.GetHeader("Content-Type"); - if (content_type.empty()) content_type = context.GetHeader("Content-Type"); - if (content_type.empty()) { - std::string encoded_payload; - impl.SetHeader( - HttpHeader("content-type", "application/x-www-form-urlencoded")); - std::string concatenated_payload; - for (auto const& p : payload) { - concatenated_payload += std::string(p.begin(), p.end()); - } - encoded_payload = impl.MakeEscapedString(concatenated_payload); - impl.SetHeader( - HttpHeader("Content-Length", std::to_string(encoded_payload.size()))); - return impl.MakeRequest(http_method, context, - {{encoded_payload.data(), encoded_payload.size()}}); - } - - std::size_t content_length = 0; - for (auto const& p : payload) { - content_length += p.size(); - } - - impl.SetHeader(HttpHeader("Content-Length", std::to_string(content_length))); - return impl.MakeRequest(http_method, context, payload); -} -#endif std::string FormatHostHeaderValue(absl::string_view hostname) { if (!absl::ConsumePrefix(&hostname, "https://")) { @@ -237,12 +195,6 @@ StatusOr> CurlRestClient::Post( auto options = internal::MergeOptions(context.options(), options_); auto impl = CreateCurlImpl(context, request, options); if (!impl.ok()) return impl.status(); - std::cout << __func__ << ": payload=Spans" << std::endl; - // for (auto const& p : payload) { - // std::cout << __func__ << ": payload=" << std::string(p.begin(), p.end()) - // << - // std::endl; - // } Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, request, **impl, payload); if (!response.ok()) return response; @@ -263,7 +215,6 @@ StatusOr> CurlRestClient::Post( out->append( absl::StrCat(i.first, "=", (*impl)->MakeEscapedString(i.second))); }); - std::cout << __func__ << ": form_payload=" << form_payload << std::endl; std::vector> span_payload{form_payload}; Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, request, @@ -273,21 +224,6 @@ StatusOr> CurlRestClient::Post( new CurlRestResponse(std::move(options), std::move(*impl)))}; } -// StatusOr> CurlRestClient::Post( -// RestContext& context, RestRequest const& request, -// nlohmann::json const& json_payload) { -// context.AddHeader("content-type", "application/json"); -// auto options = internal::MergeOptions(context.options(), options_); -// auto impl = CreateCurlImpl(context, request, options); -// if (!impl.ok()) return impl.status(); -// Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, -// context, -// request, **impl, json_payload); -// if (!response.ok()) return response; -// return {std::unique_ptr( -// new CurlRestResponse(std::move(options), std::move(*impl)))}; -// } - StatusOr> CurlRestClient::Put( RestContext& context, RestRequest const& request, std::vector> const& payload) { diff --git a/google/cloud/internal/curl_rest_client.h b/google/cloud/internal/curl_rest_client.h index 0d3cd8c1e696d..d5a3cfdb9500c 100644 --- a/google/cloud/internal/curl_rest_client.h +++ b/google/cloud/internal/curl_rest_client.h @@ -66,9 +66,6 @@ class CurlRestClient : public RestClient { RestContext& context, RestRequest const& request, std::vector> const& form_data) override; - // StatusOr> Post( - // RestContext& context, RestRequest const& request, - // nlohmann::json const& json_payload) override; StatusOr> Put( RestContext& context, RestRequest const& request, std::vector> const& payload) override; diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 5f280f8dbcdd1..4c944560b148c 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -85,6 +85,32 @@ nlohmann::json TestContents() { std::string MakeTestContents() { return TestContents().dump(); } +MATCHER_P(JsonPayloadIs, payload, "JSON payload is") { + if ((arg.empty() && !payload.empty()) || (!arg.empty() && payload.empty())) { + return false; + } + if (arg.empty() && payload.empty()) return true; + + // When calling RestClient::Post with string or json, the payload will be a + // std::vector with at most 1 element. + auto json_payload = nlohmann::json{payload[0]}; + auto json_arg = + nlohmann::json::parse(std::string{arg[0].data(), arg[0].size()}); + if (json_arg.is_discarded()) return false; + + // The value of the subject_token is based on a random key, so just check if + // it is present. + if (json_arg.erase("subject_token") != 1) return false; + if (json_arg.size() != json_payload.size()) return false; + + // Compare the remaining items. + for (auto const& p : json_payload.items()) { + auto a = json_arg.value(p.key(), ""); + if (a != p.value()) return false; + } + return true; +} + /// @test Verify that we can create service account credentials from a keyfile. TEST(GDCHServiceAccountCredentialsTest, RefreshingSendsCorrectRequestBodyAndParsesResponse) { @@ -105,25 +131,27 @@ TEST(GDCHServiceAccountCredentialsTest, {"iss", iss_sub_value}, {"sub", iss_sub_value}, {"aud", kTokenUri}, {"iat", iat}, {"exp", exp}, }; - auto const assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( - expected_header.dump(), expected_payload.dump(), kPrivateKey); + // auto const assertion = GDCHServiceAccountCredentials::MakeJWTAssertion( + // expected_header.dump(), expected_payload.dump(), kPrivateKey); auto token_client = [=] { using FormDataType = std::vector>; auto mock = std::make_unique(); auto expected_request = Property(&RestRequest::path, kTokenUri); - // auto expected_form_data = MatcherCast(AllOf( - // Contains(Pair("grant_type", - // "urn:ietf:params:oauth:token-type:token-exchange")), - // Contains(Pair("audience", kAudience)), - // Contains(Pair("requested_token_type", - // "urn:ietf:params:oauth:token-type:access_token")), - // Contains(Pair("subject_token", assertion)), - // Contains(Pair(absl::Span("subject_token_type"), - // absl::Span("urn:k8s:params:oauth:token-type:serviceaccount"))))); - EXPECT_CALL(*mock, - Post(_, expected_request, ::testing::A())) + auto expected_form_data = MatcherCast(JsonPayloadIs( + nlohmann::json{std::unordered_map{ + std::pair{ + "grant_type", + "urn:ietf:params:oauth:token-type:token-exchange"}, + std::pair{"audience", kAudience}, + std::pair{ + "requested_token_type", + "urn:ietf:params:oauth:token-type:access_token"}, + std::pair{ + "subject_token_type", + "urn:k8s:params:oauth:token-type:serviceaccount"}}})); + + EXPECT_CALL(*mock, Post(_, expected_request, expected_form_data)) .WillOnce([post_response]() { auto response = std::make_unique(); EXPECT_CALL(*response, StatusCode) @@ -380,19 +408,16 @@ TEST(GDCHServiceAccountCredentialsTest, auto actual_payload = GDCHServiceAccountCredentials::CreateRefreshPayload(*info, now); - - // EXPECT_THAT( - // actual_payload, - // IsOkAndHolds(Contains(Pair("grant_type", - // "urn:ietf:params:oauth:token-type:token-exchange")))); - // EXPECT_THAT(actual_payload, Contains(Pair("audience", kAudience))); - // EXPECT_THAT(actual_payload, - // Contains(Pair("requested_token_type", - // "urn:ietf:params:oauth:token-type:access_token"))); - // EXPECT_THAT(actual_payload, Contains(Pair("subject_token", assertion))); - // EXPECT_THAT(actual_payload, - // Contains(Pair("subject_token_type", - // "urn:k8s:params:oauth:token-type:serviceaccount"))); + ASSERT_STATUS_OK(actual_payload); + EXPECT_THAT(actual_payload->value("grant_type", ""), + Eq("urn:ietf:params:oauth:token-type:token-exchange")); + EXPECT_THAT(actual_payload->value("audience", ""), Eq(kAudience)); + EXPECT_THAT(actual_payload->value("requested_token_type", ""), + Eq("urn:ietf:params:oauth:token-type:access_token")); + EXPECT_THAT(actual_payload->value("subject_token", ""), + Not(::testing::IsEmpty())); + EXPECT_THAT(actual_payload->value("subject_token_type", ""), + Eq("urn:k8s:params:oauth:token-type:serviceaccount")); } /// @test Parsing a refresh response with missing fields results in failure. diff --git a/google/cloud/internal/openssl/sign_using_sha256.cc b/google/cloud/internal/openssl/sign_using_sha256.cc index acc3b1e21ceba..0c5644e974e9d 100644 --- a/google/cloud/internal/openssl/sign_using_sha256.cc +++ b/google/cloud/internal/openssl/sign_using_sha256.cc @@ -17,6 +17,7 @@ #include "google/cloud/internal/base64_transforms.h" #include "google/cloud/internal/make_status.h" #include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" #include #include #include @@ -48,6 +49,7 @@ struct OpenSslDeleter { void operator()(EVP_PKEY* ptr) { EVP_PKEY_free(ptr); } void operator()(BIO* ptr) { BIO_free(ptr); } + void operator()(ECDSA_SIG* ptr) { ECDSA_SIG_free(ptr); } }; std::unique_ptr GetDigestCtx() { @@ -84,9 +86,8 @@ Status DERToRawSignature(unsigned char const* der_sig, size_t der_len, GCP_ERROR_INFO()); } - // d2i_ECDSA_SIG modifies the input pointer, so make a copy - unsigned char const* p = der_sig; - ECDSA_SIG* ecdsa_sig = d2i_ECDSA_SIG(NULL, &p, der_len); + auto ecdsa_sig = std::unique_ptr( + d2i_ECDSA_SIG(NULL, &der_sig, der_len)); if (!ecdsa_sig) { char err_buf[256]; @@ -96,43 +97,40 @@ Status DERToRawSignature(unsigned char const* der_sig, size_t der_len, GCP_ERROR_INFO()); } - const BIGNUM *r, *s; - ECDSA_SIG_get0(ecdsa_sig, &r, &s); + const BIGNUM* r; + const BIGNUM* s; + ECDSA_SIG_get0(ecdsa_sig.get(), &r, &s); if (!r || !s) { auto err_msg = "Error: Could not get r or s from ECDSA_SIG."; - ECDSA_SIG_free(ecdsa_sig); return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); } raw_sig.resize(2 * coord_size); unsigned char* raw_sig_ptr = raw_sig.data(); - // Convert r to binary, padded to coord_size + auto constexpr kErrorMessage = + R"""(Error converting %s to binary (expected %d bytes, got %d): %s)"""; + // Convert r to binary, padded to coord_size. int r_len = BN_bn2binpad(r, &raw_sig_ptr[0], coord_size); if (r_len != coord_size) { char err_buf[256]; ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); auto err_msg = - absl::StrCat("Error converting r to binary (expected ", coord_size, - " bytes, got ", r_len, "): ", err_buf); - ECDSA_SIG_free(ecdsa_sig); + absl::StrFormat(kErrorMessage, "r", coord_size, r_len, err_buf); return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); } - // Convert s to binary, padded to coord_size + // Convert s to binary, padded to coord_size. int s_len = BN_bn2binpad(s, &raw_sig_ptr[coord_size], coord_size); if (s_len != coord_size) { char err_buf[256]; ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); auto err_msg = - absl::StrCat("Error converting s to binary (expected ", coord_size, - " bytes, got ", s_len, "): ", err_buf); - ECDSA_SIG_free(ecdsa_sig); + absl::StrFormat(kErrorMessage, "s", coord_size, s_len, err_buf); return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); } - ECDSA_SIG_free(ecdsa_sig); return {}; } diff --git a/google/cloud/internal/rest_client.h b/google/cloud/internal/rest_client.h index 4e286631864f5..8311baacf985f 100644 --- a/google/cloud/internal/rest_client.h +++ b/google/cloud/internal/rest_client.h @@ -86,9 +86,6 @@ class RestClient { virtual StatusOr> Post( RestContext& context, RestRequest const& request, std::vector> const& form_data) = 0; - // virtual StatusOr> Post( - // RestContext& context, RestRequest const& request, - // nlohmann::json const& json_payload) = 0; virtual StatusOr> Put( RestContext& context, RestRequest const& request, std::vector> const& payload) = 0; diff --git a/google/cloud/testing_util/mock_rest_client.h b/google/cloud/testing_util/mock_rest_client.h index 60ea020a086d3..56dc309898368 100644 --- a/google/cloud/testing_util/mock_rest_client.h +++ b/google/cloud/testing_util/mock_rest_client.h @@ -45,11 +45,6 @@ class MockRestClient : public rest_internal::RestClient { rest_internal::RestRequest const& request, (std::vector> const&)), (override)); - // MOCK_METHOD(StatusOr>, Post, - // (rest_internal::RestContext&, - // rest_internal::RestRequest const& request, - // (nlohmann::json const&)), - // (override)); MOCK_METHOD(StatusOr>, Put, (rest_internal::RestContext&, rest_internal::RestRequest const&, std::vector> const&), From 791b2bf1728481a9ac3a9ba6367f21b2fc899d6b Mon Sep 17 00:00:00 2001 From: Scott Hart Date: Tue, 26 May 2026 17:00:50 -0400 Subject: [PATCH 9/9] some cleanup --- ci/cloudbuild/builds/coverage.sh | 2 +- google/cloud/internal/credentials_impl.h | 1 - google/cloud/internal/curl_rest_client.cc | 1 + google/cloud/internal/make_jwt_assertion.h | 1 + .../internal/oauth2_gdch_service_account_credentials_test.cc | 4 ++-- google/cloud/internal/oauth2_google_credentials.cc | 1 + google/cloud/internal/oauth2_service_account_credentials.cc | 1 + google/cloud/internal/openssl/sign_using_sha256.cc | 1 - google/cloud/internal/rest_client.h | 1 - 9 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ci/cloudbuild/builds/coverage.sh b/ci/cloudbuild/builds/coverage.sh index a96431fc1be62..e60e3ab160307 100755 --- a/ci/cloudbuild/builds/coverage.sh +++ b/ci/cloudbuild/builds/coverage.sh @@ -71,7 +71,7 @@ for arg in "${integration_args[@]}"; do esac i=$((++i)) done -#integration::bazel_with_emulators coverage "${args[@]}" "${integration_args[@]}" +integration::bazel_with_emulators coverage "${args[@]}" "${integration_args[@]}" # Where does this token come from? For triggered ci/pr builds GCB will securely # inject this into the environment. See the "secretEnv" setting in the diff --git a/google/cloud/internal/credentials_impl.h b/google/cloud/internal/credentials_impl.h index 37435b39f05a3..d985e0c8bbdb7 100644 --- a/google/cloud/internal/credentials_impl.h +++ b/google/cloud/internal/credentials_impl.h @@ -223,7 +223,6 @@ class GDCHServiceAccountConfig : public Credentials { void dispatch(CredentialsVisitor& v) const override { v.visit(*this); } std::optional file_path_ = std::nullopt; - ; std::string json_object_; std::string audience_; Options options_; diff --git a/google/cloud/internal/curl_rest_client.cc b/google/cloud/internal/curl_rest_client.cc index c9489b0763c86..615af1976e78a 100644 --- a/google/cloud/internal/curl_rest_client.cc +++ b/google/cloud/internal/curl_rest_client.cc @@ -215,6 +215,7 @@ StatusOr> CurlRestClient::Post( out->append( absl::StrCat(i.first, "=", (*impl)->MakeEscapedString(i.second))); }); + // TODO(sdhart): do we still need this conversion? std::vector> span_payload{form_payload}; Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, request, diff --git a/google/cloud/internal/make_jwt_assertion.h b/google/cloud/internal/make_jwt_assertion.h index e28a226337774..f77b8c0f527e3 100644 --- a/google/cloud/internal/make_jwt_assertion.h +++ b/google/cloud/internal/make_jwt_assertion.h @@ -35,6 +35,7 @@ StatusOr MakeJWTAssertionNoThrow( std::string const& header, std::string const& payload, std::string const& pem_contents, SignatureFormat format = SignatureFormat::kDER); + } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace cloud diff --git a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc index 4c944560b148c..517833d6369c7 100644 --- a/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -49,6 +49,7 @@ using ::testing::ByMove; using ::testing::Contains; using ::testing::Eq; using ::testing::HasSubstr; +using ::testing::IsEmpty; using ::testing::MatcherCast; using ::testing::Not; using ::testing::Pair; @@ -414,8 +415,7 @@ TEST(GDCHServiceAccountCredentialsTest, EXPECT_THAT(actual_payload->value("audience", ""), Eq(kAudience)); EXPECT_THAT(actual_payload->value("requested_token_type", ""), Eq("urn:ietf:params:oauth:token-type:access_token")); - EXPECT_THAT(actual_payload->value("subject_token", ""), - Not(::testing::IsEmpty())); + EXPECT_THAT(actual_payload->value("subject_token", ""), Not(IsEmpty())); EXPECT_THAT(actual_payload->value("subject_token_type", ""), Eq("urn:k8s:params:oauth:token-type:serviceaccount")); } diff --git a/google/cloud/internal/oauth2_google_credentials.cc b/google/cloud/internal/oauth2_google_credentials.cc index 4fa1d328f461a..e9f2156685956 100644 --- a/google/cloud/internal/oauth2_google_credentials.cc +++ b/google/cloud/internal/oauth2_google_credentials.cc @@ -99,6 +99,7 @@ StatusOr> LoadCredsFromString( std::make_unique( config, std::move(rest_stub))); } + // TODO(sdhart): this needs test coverage if (cred_type == "gdch_service_account") { auto info = GDCHServiceAccountCredentials::Parse(contents, path); if (!info) return std::move(info).status(); diff --git a/google/cloud/internal/oauth2_service_account_credentials.cc b/google/cloud/internal/oauth2_service_account_credentials.cc index 8da9a3d550a6f..f8e10a73ee7f3 100644 --- a/google/cloud/internal/oauth2_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_service_account_credentials.cc @@ -217,6 +217,7 @@ StatusOr ParseServiceAccountRefreshResponse( "Could not find all required fields in response (access_token," " expires_in, token_type) while trying to obtain an access token for" " service account credentials."; + // TODO(sdhart): verify this is the error we want to return. return internal::InternalError(error_payload, GCP_ERROR_INFO()); // return AsStatus(status_code, error_payload); } diff --git a/google/cloud/internal/openssl/sign_using_sha256.cc b/google/cloud/internal/openssl/sign_using_sha256.cc index 0c5644e974e9d..22ff252b4f7b6 100644 --- a/google/cloud/internal/openssl/sign_using_sha256.cc +++ b/google/cloud/internal/openssl/sign_using_sha256.cc @@ -16,7 +16,6 @@ #include "google/cloud/internal/sign_using_sha256.h" #include "google/cloud/internal/base64_transforms.h" #include "google/cloud/internal/make_status.h" -#include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" #include #include diff --git a/google/cloud/internal/rest_client.h b/google/cloud/internal/rest_client.h index 8311baacf985f..d554d4c6ea87c 100644 --- a/google/cloud/internal/rest_client.h +++ b/google/cloud/internal/rest_client.h @@ -23,7 +23,6 @@ #include "google/cloud/rest_options.h" #include "google/cloud/status_or.h" #include "google/cloud/version.h" -#include #include #include #include