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/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 29c884c38d640..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()` * @@ -475,6 +509,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/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..d985e0c8bbdb7 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,28 @@ 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..615af1976e78a 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 { @@ -214,8 +215,11 @@ StatusOr> CurlRestClient::Post( out->append( absl::StrCat(i.first, "=", (*impl)->MakeEscapedString(i.second))); }); - Status response = MakeRequestWithPayload(CurlImpl::HttpMethod::kPost, context, - request, **impl, {form_payload}); + // TODO(sdhart): do we still need this conversion? + 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)))}; diff --git a/google/cloud/internal/make_jwt_assertion.cc b/google/cloud/internal/make_jwt_assertion.cc index eacea0bec5adc..2f1224e8ee5ba 100644 --- a/google/cloud/internal/make_jwt_assertion.cc +++ b/google/cloud/internal/make_jwt_assertion.cc @@ -23,10 +23,11 @@ namespace internal { StatusOr MakeJWTAssertionNoThrow(std::string const& header, std::string const& payload, - std::string const& pem_contents) { + std::string const& pem_contents, + SignatureFormat format) { auto const body = UrlsafeBase64Encode(header) + '.' + UrlsafeBase64Encode(payload); - auto pem_signature = internal::SignUsingSha256(body, pem_contents); + 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 c515b906b65dc..f77b8c0f527e3 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 @@ -23,9 +24,17 @@ namespace google { namespace cloud { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace internal { -StatusOr MakeJWTAssertionNoThrow(std::string const& header, - std::string const& payload, - std::string const& pem_contents); + +/** + * 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 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..73b662a59fa97 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -0,0 +1,283 @@ +// 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/make_jwt_assertion.h" +#include "google/cloud/internal/make_status.h" +#include "google/cloud/internal/oauth2_google_credentials.h" +#include "google/cloud/internal/parse_service_account_p12_file.h" +#include "google/cloud/internal/rest_response.h" +#include "absl/strings/str_join.h" +#include "absl/strings/str_split.h" +#include +#include +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +using ::google::cloud::internal::MakeJWTAssertionNoThrow; + +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( + "Invalid GDCHServiceAccountCredentials, parsing failed on ", + "data loaded from ", source)); + } + + using Validator = + 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{}; + 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", 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(); + }}, + {"token_uri", required_field, + [&](Info& info, nlohmann::json::iterator const& l) { + info.token_uri = l->get(); + }}}; + + Info info; + 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 +GDCHServiceAccountCredentials::AssertionComponentsFromInfo( + 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(); + // 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()); +} + +StatusOr GDCHServiceAccountCredentials::MakeJWTAssertion( + std::string const& header, std::string const& payload, + std::string const& pem_contents) { + return internal::MakeJWTAssertionNoThrow(header, payload, pem_contents, + internal::SignatureFormat::kRaw); +} + +StatusOr GDCHServiceAccountCredentials::CreateRefreshPayload( + Info const& info, std::chrono::system_clock::time_point now) { + auto [header, payload] = AssertionComponentsFromInfo(info, now); + auto jwt = MakeJWTAssertion(header, payload, info.private_key); + 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_type", "urn:k8s:params:oauth:token-type:serviceaccount"}}; +} + +StatusOr GDCHServiceAccountCredentials::ParseRefreshResponse( + 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 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_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 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}; +} + +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()); + } + // 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 = MakeJWTAssertion(header, payload, info.private_key); + if (!jwt) return jwt.status(); + return StatusOr>( + std::unique_ptr( + 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, + 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 + // 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 CreateFromJsonContents(std::move(contents), options, + std::move(client_factory)); +} + +GDCHServiceAccountCredentials::GDCHServiceAccountCredentials( + Info info, Options options, HttpClientFactory client_factory) + : info_(std::move(info)), + options_(std::move(options)), + client_factory_(std::move(client_factory)) { + if (options_.has()) { + info_.audience = options_.get(); + } +} + +StatusOr GDCHServiceAccountCredentials::GetToken( + std::chrono::system_clock::time_point tp) { + Options options = options_; + if (!info_.ca_cert_path.empty()) { + options.set(info_.ca_cert_path); + } + 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()}); + if (!response) return std::move(response).status(); + if (IsHttpError(**response)) return AsStatus(std::move(**response)); + return ParseRefreshResponse(**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..4b56b0388ac93 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -0,0 +1,139 @@ +// 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_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 + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +/** + * 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. + * + * This class description assumes that you are familiar with [service accounts], + * and [service account keys]. + * + * 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 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( + std::string const& contents, Options const& options, + HttpClientFactory client_factory); + + /// Creates a GDCHServiceAccountCredentials from a file at the specified path. + 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); + + /// 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 + 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 StatusOr CreateRefreshPayload( + Info const& info, std::chrono::system_clock::time_point now); + + 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: + GDCHServiceAccountCredentials(Info info, Options options, + HttpClientFactory client_factory); + + Info info_; + Options options_; + HttpClientFactory client_factory_; +}; + +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..517833d6369c7 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -0,0 +1,489 @@ +// 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_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/status_matchers.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" +#include +#include +#include + +namespace google { +namespace cloud { +namespace oauth2_internal { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { + +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::StatusIs; +using ::testing::_; +using ::testing::AllOf; +using ::testing::ByMove; +using ::testing::Contains; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::MatcherCast; +using ::testing::Not; +using ::testing::Pair; +using ::testing::Property; +using ::testing::Return; + +using MockHttpClientFactory = + ::testing::MockFunction( + Options const&)>; + +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. +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"; +auto constexpr kTokenUri = "https://gdc.token.uri/v1/token"; + +nlohmann::json TestContents() { + return nlohmann::json{ + {"project", kProjectId}, {"private_key_id", kPrivateKeyId}, + {"private_key", kPrivateKey}, {"name", kServiceIdentityName}, + {"ca_cert_path", kCaCertPath}, {"token_uri", kTokenUri}, + }; +} + +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) { + 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 const expected_header = + 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, + ":", kServiceIdentityName); + auto const expected_payload = nlohmann::json{ + {"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 token_client = [=] { + using FormDataType = std::vector>; + auto mock = std::make_unique(); + auto expected_request = Property(&RestRequest::path, kTokenUri); + 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) + .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 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. + auto token = (*credentials)->GetToken(tp); + ASSERT_STATUS_OK(token); + 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)); +} + +/// @test Verify that `nlohmann::json::parse()` failures are reported as +/// is_discarded. +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. + 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(GDCHServiceAccountCredentialsTest, ParseSimple) { + std::string contents = R"""({ + "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" +})"""; + + 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")); + 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. +TEST(GDCHServiceAccountCredentialsTest, ParseInvalidContentsFails) { + std::string config = R"""( not-a-valid-json-string )"""; + + auto actual = + GDCHServiceAccountCredentials::Parse(config, "test-as-a-source"); + EXPECT_THAT(actual, + StatusIs(Not(StatusCode::kOk), + AllOf(HasSubstr("Invalid GDCHServiceAccountCredentials"), + HasSubstr("test-as-a-source")))); +} + +/// @test Parsing a service account JSON string should detect empty fields. +TEST(GDCHServiceAccountCredentialsTest, ParseEmptyFieldFails) { + std::string contents = R"""({ + "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", "private_key_id", "private_key", "name", "token_uri"}) { + auto json = nlohmann::json::parse(contents); + json[field] = ""; + auto actual = + GDCHServiceAccountCredentials::Parse(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(GDCHServiceAccountCredentialsTest, ParseInvalidTypeFieldFails) { + std::string contents = R"""({ + "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", "private_key_id", "private_key", "name", + "ca_cert_path", "token_uri"}) { + auto json = nlohmann::json::parse(contents); + json[field] = true; + auto actual = + GDCHServiceAccountCredentials::Parse(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(GDCHServiceAccountCredentialsTest, ParseMissingFieldFails) { + std::string contents = R"""({ + "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", + "token_uri": "https://gdc.token.uri/v1/token" +})"""; + + for (auto const& field : + {"project", "private_key_id", "private_key", "name", "token_uri"}) { + auto json = nlohmann::json::parse(contents); + json.erase(field); + auto actual = + GDCHServiceAccountCredentials::Parse(json.dump(), "test-data"); + EXPECT_THAT(actual, + StatusIs(Not(StatusCode::kOk), + AllOf(HasSubstr(field), HasSubstr(" field is missing"), + HasSubstr("test-data")))); + } +} + +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()); + ASSERT_STATUS_OK(credentials); + EXPECT_THAT((*credentials)->project_id(), IsOkAndHolds(kProjectId)); + EXPECT_THAT((*credentials)->project_id({}), IsOkAndHolds(kProjectId)); +} + +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()); + 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(GDCHServiceAccountCredentialsTest, AssertionComponentsFromInfo) { + auto info = GDCHServiceAccountCredentials::Parse(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + auto const now = std::chrono::system_clock::now(); + auto components = + GDCHServiceAccountCredentials::AssertionComponentsFromInfo(*info, now); + + auto header = nlohmann::json::parse(components.first); + EXPECT_THAT(header.value("alg", ""), Eq("ES256")); + 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_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_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 +/// keyfile. +TEST(GDCHServiceAccountCredentialsTest, MakeGDCHJWTAssertion) { + auto info = GDCHServiceAccountCredentials::Parse(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + + auto const tp = std::chrono::system_clock::from_time_t(kFixedJwtTimestamp); + auto components = + 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, '.'); + 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 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", "ES256"}, {"typ", "JWT"}, {"kid", kPrivateKeyId}}; + EXPECT_THAT(header, Eq(expected_header)); + + 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", iss_sub_value}, {"sub", iss_sub_value}, {"aud", kTokenUri}, + {"iat", iat}, {"exp", exp}, + }; + + EXPECT_THAT(payload, Eq(expected_payload)); +} + +/// @test Verify we can construct a service account refresh payload given the +/// info parsed from a keyfile. +TEST(GDCHServiceAccountCredentialsTest, + CreateGDCHServiceAccountRefreshPayload) { + auto info = GDCHServiceAccountCredentials::Parse(MakeTestContents(), "test"); + ASSERT_STATUS_OK(info); + info->audience = kAudience; + auto const now = std::chrono::system_clock::now(); + auto components = + 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); + 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(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. +TEST(GDCHServiceAccountCredentialsTest, + ParseGDCHServiceAccountRefreshResponseMissingFields) { + std::string r1 = R"""({})"""; + // Does not have access_token. + std::string r2 = R"""({ + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3599 +})"""; + + 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 = + GDCHServiceAccountCredentials::ParseRefreshResponse(*mock_response1, now); + EXPECT_THAT(status, + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("Could not find all required fields"))); + + status = + GDCHServiceAccountCredentials::ParseRefreshResponse(*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(GDCHServiceAccountCredentialsTest, + ParseGDCHServiceAccountRefreshResponse) { + auto const expires_in = std::chrono::seconds(1000); + std::string r1 = R"""({ + "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(); + 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 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")); +} + +} // 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..e9f2156685956 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))); } + // 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(); + return GDCHServiceAccountCredentials::CreateFromInfo( + *std::move(info), options, std::move(client_factory)); + } + return internal::InvalidArgumentError( "Unsupported credential type (" + cred_type + ") when reading Application Default Credentials file " diff --git a/google/cloud/internal/oauth2_service_account_credentials.cc b/google/cloud/internal/oauth2_service_account_credentials.cc index 9bfb7281eaf69..f8e10a73ee7f3 100644 --- a/google/cloud/internal/oauth2_service_account_credentials.cc +++ b/google/cloud/internal/oauth2_service_account_credentials.cc @@ -217,7 +217,9 @@ 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); + // 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); } 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..22ff252b4f7b6 100644 --- a/google/cloud/internal/openssl/sign_using_sha256.cc +++ b/google/cloud/internal/openssl/sign_using_sha256.cc @@ -16,8 +16,11 @@ #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_format.h" #include +#include #include +#include #include #include #include @@ -45,6 +48,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() { @@ -74,10 +78,66 @@ std::string CaptureSslErrors() { return msg; } +Status DERToRawSignature(unsigned char const* der_sig, size_t der_len, + int coord_size, std::vector& raw_sig) { + if (!der_sig || der_len == 0) { + return internal::InvalidArgumentError("Input DER signature is empty.", + GCP_ERROR_INFO()); + } + + auto ecdsa_sig = std::unique_ptr( + d2i_ECDSA_SIG(NULL, &der_sig, der_len)); + + if (!ecdsa_sig) { + char err_buf[256]; + ERR_error_string_n(ERR_get_error(), err_buf, sizeof(err_buf)); + return InvalidArgumentError( + absl::StrCat("Error parsing DER signature: ", err_buf), + GCP_ERROR_INFO()); + } + + 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."; + return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); + } + + raw_sig.resize(2 * coord_size); + unsigned char* raw_sig_ptr = raw_sig.data(); + + 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::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. + 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::StrFormat(kErrorMessage, "s", coord_size, s_len, err_buf); + return internal::InvalidArgumentError(err_msg, GCP_ERROR_INFO()); + } + + return {}; +} + } // namespace StatusOr> SignUsingSha256( - std::string const& str, std::string const& pem_contents) { + std::string const& str, std::string const& pem_contents, + SignatureFormat format) { 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 der_sig{buffer.begin(), + std::next(buffer.begin(), actual_len)}; + if (format == SignatureFormat::kDER) { + return der_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 b6975d37b8d31..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. * @@ -34,7 +40,8 @@ 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, + SignatureFormat format = SignatureFormat::kDER); } // 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..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) { @@ -221,6 +225,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, Not(IsEmpty())); + + 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()), + 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