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/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..6afd0bce3fae7 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.cc @@ -0,0 +1,272 @@ +// 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 + +StatusOr +GDCHServiceAccountCredentials::Parse(std::string const& content, + std::string const& source) { + auto credentials = nlohmann::json::parse(content, nullptr, false); + if (credentials.is_discarded() || !credentials.is_object()) { + 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 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 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.is_object() || + 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) { + // 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, std::string const& audience, + Options const& options, HttpClientFactory client_factory) { + auto info = Parse(contents, "memory"); + if (!info) return info.status(); + info->audience = audience; + return CreateFromInfo(*std::move(info), options, std::move(client_factory)); +} + +StatusOr> +GDCHServiceAccountCredentials::CreateFromFilePath( + std::string const& path, std::string const& audience, + 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), audience, 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)) {} + +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..03b5aeecc64d5 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials.h @@ -0,0 +1,140 @@ +// 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 +#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, std::string const& audience, + Options const& options, HttpClientFactory client_factory); + + /// Creates a GDCHServiceAccountCredentials from a file at the specified path. + static StatusOr> CreateFromFilePath( + std::string const& path, std::string const& audience, + 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..772930cc68c31 --- /dev/null +++ b/google/cloud/internal/oauth2_gdch_service_account_credentials_test.cc @@ -0,0 +1,466 @@ +// 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::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::Eq; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::MatcherCast; +using ::testing::Not; +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; + + auto json_arg = + nlohmann::json::parse(std::string{arg[0].data(), arg[0].size()}); + if (json_arg.is_discarded() || !json_arg.is_object()) 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() != payload.size()) return false; + + // Compare the remaining items. + auto items = payload.items(); + return std::all_of(items.begin(), items.end(), [&json_arg](auto const& p) { + return p.value() == json_arg.value(p.key(), ""); + }); + 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 expected_json_payload = nlohmann::json{ + {"grant_type", "urn:ietf:params:oauth:token-type:token-exchange"}, + {"audience", kAudience}, + {"requested_token_type", "urn:ietf:params:oauth:token-type:access_token"}, + {"subject_token_type", "urn:k8s:params:oauth:token-type:serviceaccount"}}; + + 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(expected_json_payload)); + + 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(), kAudience, Options{}, + 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(), kAudience, Options{}, + mock_http_client_factory.AsStdFunction()); + ASSERT_STATUS_OK(credentials); + EXPECT_THAT((*credentials)->project_id(), IsOkAndHolds(kProjectId)); + EXPECT_THAT((*credentials)->project_id({}), IsOkAndHolds(kProjectId)); +} + +/// @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/openssl/sign_using_sha256.cc b/google/cloud/internal/openssl/sign_using_sha256.cc index 3e19700d1d573..ec44c9ead02a5 100644 --- a/google/cloud/internal/openssl/sign_using_sha256.cc +++ b/google/cloud/internal/openssl/sign_using_sha256.cc @@ -16,8 +16,12 @@ #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 #include +#include #include #include #include @@ -45,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() { @@ -74,10 +79,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 InvalidArgumentError("Input DER signature is empty.", + GCP_ERROR_INFO()); + } + + auto ecdsa_sig = std::unique_ptr( + d2i_ECDSA_SIG(nullptr, &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 const* err_msg = "Error: Could not get r or s from ECDSA_SIG."; + return 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 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 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 +216,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