diff --git a/google/cloud/internal/unified_grpc_credentials.cc b/google/cloud/internal/unified_grpc_credentials.cc index 7a692aaa14728..36cbe33464a91 100644 --- a/google/cloud/internal/unified_grpc_credentials.cc +++ b/google/cloud/internal/unified_grpc_credentials.cc @@ -15,18 +15,26 @@ #include "google/cloud/internal/unified_grpc_credentials.h" #include "google/cloud/grpc_error_delegate.h" #include "google/cloud/grpc_options.h" +#include "google/cloud/internal/filesystem.h" +#include "google/cloud/internal/getenv.h" #include "google/cloud/internal/grpc_access_token_authentication.h" #include "google/cloud/internal/grpc_api_key_authentication.h" #include "google/cloud/internal/grpc_channel_credentials_authentication.h" #include "google/cloud/internal/grpc_compute_engine_authentication.h" #include "google/cloud/internal/grpc_impersonate_service_account.h" #include "google/cloud/internal/grpc_service_account_authentication.h" +#include "google/cloud/internal/make_status.h" +#include "absl/strings/str_cat.h" #include #if __has_include() #include #endif #include #include +#include +#include +#include +#include namespace { @@ -78,6 +86,159 @@ class GrpcErrorCredentialsAuthentication : public GrpcAuthenticationStrategy { Status error_status_; }; +namespace { + +// The lookup below mirrors `oauth2_internal::GoogleDefaultCredentials()`. The +// REST helpers it would reuse are compiled into `rest_internal`, which depends +// on libcurl; `grpc_utils` does not link that library, so the lookup is +// duplicated here rather than adding the dependency. +std::string AdcFilePathFromEnvVarOrEmpty() { + return GetEnv("GOOGLE_APPLICATION_CREDENTIALS").value_or(""); +} + +std::string AdcFilePathFromWellKnownPathOrEmpty() { + std::optional const override_path = + GetEnv("GOOGLE_GCLOUD_ADC_PATH_OVERRIDE"); + if (override_path.has_value()) return *override_path; +#ifdef _WIN32 + std::optional const root = GetEnv("APPDATA"); + constexpr std::string_view kSuffix = + "/gcloud/application_default_credentials.json"; +#else + std::optional const root = GetEnv("HOME"); + constexpr std::string_view kSuffix = + "/.config/gcloud/application_default_credentials.json"; +#endif + if (!root.has_value()) return ""; + return absl::StrCat(*root, kSuffix); +} + +// gRPC's Google Default credentials accept a fixed set of `type` values; each +// of its credential factories checks the field again and returns null for +// anything else. +AdcLookup ClassifyAdcFile(std::string path, std::string contents) { + AdcLookup lookup; + lookup.path = std::move(path); + lookup.contents = std::move(contents); + nlohmann::json const json = + nlohmann::json::parse(*lookup.contents, nullptr, false); + if (json.is_discarded() || !json.is_object()) { + lookup.source = AdcSource::kNotJsonObject; + return lookup; + } + auto const type = json.find("type"); + if (type != json.end() && type->is_string()) { + lookup.type = type->get(); + } + if (lookup.type == "service_account") { + lookup.source = AdcSource::kServiceAccount; + } else if (lookup.type == "authorized_user") { + lookup.source = AdcSource::kAuthorizedUser; + } else if (lookup.type == "external_account") { + lookup.source = AdcSource::kExternalAccount; + } else { + lookup.source = AdcSource::kUnsupportedType; + } + return lookup; +} + +std::shared_ptr ErrorStrategy(Status status) { + return std::make_shared( + ErrorCredentialsConfig{std::move(status)}); +} + +std::shared_ptr MakeSslCredentials( + Options const& options) { + grpc::SslCredentialsOptions ssl_options; + std::optional cainfo = LoadCAInfo(options); + if (cainfo) ssl_options.pem_root_certs = std::move(*cainfo); + return grpc::SslCredentials(ssl_options); +} + +// gRPC's call-credential factories return nullptr on malformed input, and +// `grpc::CompositeChannelCredentials()` dereferences its argument without +// checking for null, so we validate @p call_creds first. @p factory names the +// gRPC factory for the error message. +std::shared_ptr TlsWithCallCredentials( + Options const& options, + std::shared_ptr const& call_creds, + std::string_view factory, std::optional const& path) { + if (!call_creds) { + return ErrorStrategy(InvalidArgumentError( + absl::StrCat("Error creating ", factory, + " for Application Default Credentials", + path ? absl::StrCat(" from ", *path) : ""), + GCP_ERROR_INFO())); + } + return std::make_shared( + grpc::CompositeChannelCredentials(MakeSslCredentials(options), + call_creds)); +} + +// The strategy for `GrpcGoogleDefaultTlsOnlyOption`: the call credentials +// gRPC's `GoogleDefaultCredentials()` derives from Application Default +// Credentials (see `create_default_creds_from_path()` in gRPC), composed with +// TLS instead of letting gRPC choose between TLS and ALTS per endpoint. The +// error codes and messages follow the REST ADC loader. +std::shared_ptr MakeTlsGoogleDefaultStrategy( + GoogleDefaultCredentialsConfig const& cfg, Options const& options) { + StatusOr adc = LookupApplicationDefaultCredentials(); + if (!adc) return ErrorStrategy(std::move(adc).status()); + switch (adc->source) { + case AdcSource::kNone: + return TlsWithCallCredentials( + options, grpc::GoogleComputeEngineCredentials(), + "grpc::GoogleComputeEngineCredentials", adc->path); + case AdcSource::kServiceAccount: + return TlsWithCallCredentials( + options, grpc::ServiceAccountJWTAccessCredentials(*adc->contents), + "grpc::ServiceAccountJWTAccessCredentials", adc->path); + case AdcSource::kAuthorizedUser: + return TlsWithCallCredentials( + options, grpc::GoogleRefreshTokenCredentials(*adc->contents), + "grpc::GoogleRefreshTokenCredentials", adc->path); + case AdcSource::kExternalAccount: + return TlsWithCallCredentials( + options, + GrpcExternalAccountCredentials( + ExternalAccountConfig(*adc->contents, cfg.options())), + "grpc::ExternalAccountCredentials", adc->path); + case AdcSource::kNotJsonObject: + return ErrorStrategy(InvalidArgumentError( + absl::StrCat("Cannot open credentials file ", *adc->path, + ", it does not contain a JSON object."), + GCP_ERROR_INFO())); + case AdcSource::kUnsupportedType: + break; + } + return ErrorStrategy(InvalidArgumentError( + absl::StrCat("Unsupported credential type (", + adc->type.value_or("no type given"), + ") when reading Application Default Credentials file from ", + *adc->path, "."), + GCP_ERROR_INFO())); +} + +} // namespace + +StatusOr LookupApplicationDefaultCredentials() { + std::string path = AdcFilePathFromEnvVarOrEmpty(); + if (path.empty()) { + path = AdcFilePathFromWellKnownPathOrEmpty(); + std::error_code ec; + if (path.empty() || !exists(status(path, ec))) return AdcLookup{}; + } + 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 UnknownError("Cannot open credentials file " + path, + GCP_ERROR_INFO()); + } + std::string contents(std::istreambuf_iterator{is}, {}); + return ClassifyAdcFile(std::move(path), std::move(contents)); +} + std::shared_ptr CreateAuthenticationStrategy( google::cloud::CompletionQueue cq, Options const& options) { if (options.has()) { @@ -106,7 +267,11 @@ std::shared_ptr CreateAuthenticationStrategy( result = std::make_unique( grpc::InsecureChannelCredentials()); } - void visit(GoogleDefaultCredentialsConfig const&) override { + void visit(GoogleDefaultCredentialsConfig const& cfg) override { + if (options.get()) { + result = MakeTlsGoogleDefaultStrategy(cfg, options); + return; + } result = std::make_unique( grpc::GoogleDefaultCredentials()); } @@ -151,11 +316,8 @@ std::shared_ptr CreateAuthenticationStrategy( GCP_ERROR_INFO())}); return; } - grpc::SslCredentialsOptions ssl_options; - auto cainfo = LoadCAInfo(options); - if (cainfo) ssl_options.pem_root_certs = std::move(*cainfo); result = std::make_unique( - grpc::CompositeChannelCredentials(grpc::SslCredentials(ssl_options), + grpc::CompositeChannelCredentials(MakeSslCredentials(options), std::move(external_account_creds))); } void visit(ApiKeyConfig const& cfg) override { @@ -216,7 +378,7 @@ std::shared_ptr CreateAuthenticationStrategy( ca_cert_path = it->get(); } } - grpc::SslCredentialsOptions ssl_options; + std::shared_ptr ssl_creds; if (!ca_cert_path.empty()) { std::ifstream is(ca_cert_path); if (!is.is_open()) { @@ -226,15 +388,15 @@ std::shared_ptr CreateAuthenticationStrategy( GCP_ERROR_INFO())}); return; } + grpc::SslCredentialsOptions ssl_options; ssl_options.pem_root_certs = std::string{std::istreambuf_iterator{is.rdbuf()}, {}}; + ssl_creds = grpc::SslCredentials(ssl_options); } else { - std::optional cainfo = LoadCAInfo(options); - if (cainfo) ssl_options.pem_root_certs = std::move(*cainfo); + ssl_creds = MakeSslCredentials(options); } result = std::make_unique( - grpc::CompositeChannelCredentials(grpc::SslCredentials(ssl_options), - gdch_creds)); + grpc::CompositeChannelCredentials(ssl_creds, gdch_creds)); #else (void)cfg; result = std::make_unique( diff --git a/google/cloud/internal/unified_grpc_credentials.h b/google/cloud/internal/unified_grpc_credentials.h index b63475b39f9e8..5e881940359e0 100644 --- a/google/cloud/internal/unified_grpc_credentials.h +++ b/google/cloud/internal/unified_grpc_credentials.h @@ -19,16 +19,70 @@ #include "google/cloud/credentials.h" #include "google/cloud/future.h" #include "google/cloud/status.h" +#include "google/cloud/status_or.h" #include "google/cloud/version.h" #include #include #include +#include namespace google { namespace cloud { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN namespace internal { +/** + * Use TLS channel credentials for `MakeGoogleDefaultCredentials()`. + * + * `grpc::GoogleDefaultCredentials()` selects ALTS for the endpoints of xDS + * clusters that are not fronted by Google's edge proxies. Some such clusters, + * for example DirectPath over Interconnect, serve hosts outside Google Cloud, + * where ALTS is unavailable. With this option the authentication strategy for + * Google Default credentials composes `grpc::SslCredentials()` with the call + * credentials that Application Default Credentials resolve to, attached at the + * channel level so that gRPC's control-plane channels (such as RLS) inherit + * them. + * + * Libraries set this option for endpoints known to require TLS; it is not + * intended for applications. + */ +struct GrpcGoogleDefaultTlsOnlyOption { + using Type = bool; +}; + +/** + * The outcome of the Application Default Credentials file lookup, classified + * the way gRPC's Google Default credentials classify it. + */ +enum class AdcSource { + kNone, ///< No file found; use Compute Engine credentials. + kServiceAccount, + kAuthorizedUser, + kExternalAccount, + kNotJsonObject, ///< The file does not contain a JSON object. + kUnsupportedType, ///< A `type` gRPC's Google Default credentials reject. +}; + +/// The parsed result of `LookupApplicationDefaultCredentials()`. +struct AdcLookup { + AdcSource source = AdcSource::kNone; + std::optional path; ///< Set unless `source` is `kNone`. + std::optional contents; ///< Set unless `source` is `kNone`. + std::optional type; ///< The JSON `type`, if it is a string. +}; + +/** + * Finds and reads the Application Default Credentials file. + * + * Uses the same search order as the REST credentials: + * `GOOGLE_APPLICATION_CREDENTIALS`, then the gcloud file (the + * `GOOGLE_GCLOUD_ADC_PATH_OVERRIDE` path or the well-known location), which + * is used only if it exists. gRPC's Google Default credentials search the same + * locations, minus the override. Returns an error only when a configured file + * cannot be opened; every other outcome is described by `AdcLookup::source`. + */ +StatusOr LookupApplicationDefaultCredentials(); + class GrpcAuthenticationStrategy { public: virtual ~GrpcAuthenticationStrategy() = default; diff --git a/google/cloud/internal/unified_grpc_credentials_test.cc b/google/cloud/internal/unified_grpc_credentials_test.cc index d70e60b209e6d..365721256d62e 100644 --- a/google/cloud/internal/unified_grpc_credentials_test.cc +++ b/google/cloud/internal/unified_grpc_credentials_test.cc @@ -16,6 +16,7 @@ #include "google/cloud/common_options.h" #include "google/cloud/grpc_error_delegate.h" #include "google/cloud/grpc_options.h" +#include "google/cloud/internal/background_threads_impl.h" #include "google/cloud/internal/credentials_impl.h" #include "google/cloud/internal/filesystem.h" #include "google/cloud/internal/random.h" @@ -23,12 +24,24 @@ #include "google/cloud/testing_util/scoped_environment.h" #include "google/cloud/testing_util/status_matchers.h" #include "google/cloud/testing_util/validate_metadata.h" +#include "google/iam/credentials/v1/iamcredentials.grpc.pb.h" #include #if __has_include() #include #endif +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include namespace google { @@ -42,11 +55,14 @@ using ::google::cloud::testing_util::ScopedEnvironment; using ::google::cloud::testing_util::StatusIs; using ::google::cloud::testing_util::ValidateMetadataFixture; using ::testing::Contains; +using ::testing::Eq; using ::testing::HasSubstr; using ::testing::IsEmpty; using ::testing::IsNull; using ::testing::NotNull; +using ::testing::Optional; using ::testing::Pair; +using ::testing::StartsWith; TEST(UnifiedGrpcCredentialsTest, GrpcCredentialOption) { CompletionQueue cq; @@ -412,6 +428,746 @@ TEST(UnifiedGrpcCredentialsTest, HasSubstr("Cannot open CA certificate file"))); } +// The private key in this JSON object was invalidated (deleted) a long time +// ago. The service account and key it was created for were deleted too, and do +// not match the attributes listed here. gRPC parses the key when creating +// `ServiceAccountJWTAccessCredentials`, so it must be well-formed. +constexpr std::string_view kServiceAccountContents = R"""({ + "type": "service_account", + "project_id": "foo-project", + "private_key_id": "a1a111aa1111a11a11a11aa111a111a1a1111111", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCltiF2oP3KJJ+S\ntTc1McylY+TuAi3AdohX7mmqIjd8a3eBYDHs7FlnUrFC4CRijCr0rUqYfg2pmk4a\n6TaKbQRAhWDJ7XD931g7EBvCtd8+JQBNWVKnP9ByJUaO0hWVniM50KTsWtyX3up/\nfS0W2R8Cyx4yvasE8QHH8gnNGtr94iiORDC7De2BwHi/iU8FxMVJAIyDLNfyk0hN\neheYKfIDBgJV2v6VaCOGWaZyEuD0FJ6wFeLybFBwibrLIBE5Y/StCrZoVZ5LocFP\nT4o8kT7bU6yonudSCyNMedYmqHj/iF8B2UN1WrYx8zvoDqZk0nxIglmEYKn/6U7U\ngyETGcW9AgMBAAECggEAC231vmkpwA7JG9UYbviVmSW79UecsLzsOAZnbtbn1VLT\nPg7sup7tprD/LXHoyIxK7S/jqINvPU65iuUhgCg3Rhz8+UiBhd0pCH/arlIdiPuD\n2xHpX8RIxAq6pGCsoPJ0kwkHSw8UTnxPV8ZCPSRyHV71oQHQgSl/WjNhRi6PQroB\nSqc/pS1m09cTwyKQIopBBVayRzmI2BtBxyhQp9I8t5b7PYkEZDQlbdq0j5Xipoov\n9EW0+Zvkh1FGNig8IJ9Wp+SZi3rd7KLpkyKPY7BK/g0nXBkDxn019cET0SdJOHQG\nDiHiv4yTRsDCHZhtEbAMKZEpku4WxtQ+JjR31l8ueQKBgQDkO2oC8gi6vQDcx/CX\nZ23x2ZUyar6i0BQ8eJFAEN+IiUapEeCVazuxJSt4RjYfwSa/p117jdZGEWD0GxMC\n+iAXlc5LlrrWs4MWUc0AHTgXna28/vii3ltcsI0AjWMqaybhBTTNbMFa2/fV2OX2\nUimuFyBWbzVc3Zb9KAG4Y7OmJQKBgQC5324IjXPq5oH8UWZTdJPuO2cgRsvKmR/r\n9zl4loRjkS7FiOMfzAgUiXfH9XCnvwXMqJpuMw2PEUjUT+OyWjJONEK4qGFJkbN5\n3ykc7p5V7iPPc7Zxj4mFvJ1xjkcj+i5LY8Me+gL5mGIrJ2j8hbuv7f+PWIauyjnp\nNx/0GVFRuQKBgGNT4D1L7LSokPmFIpYh811wHliE0Fa3TDdNGZnSPhaD9/aYyy78\nLkxYKuT7WY7UVvLN+gdNoVV5NsLGDa4cAV+CWPfYr5PFKGXMT/Wewcy1WOmJ5des\nAgMC6zq0TdYmMBN6WpKUpEnQtbmh3eMnuvADLJWxbH3wCkg+4xDGg2bpAoGAYRNk\nMGtQQzqoYNNSkfus1xuHPMA8508Z8O9pwKU795R3zQs1NAInpjI1sOVrNPD7Ymwc\nW7mmNzZbxycCUL/yzg1VW4P1a6sBBYGbw1SMtWxun4ZbnuvMc2CTCh+43/1l+FHe\nMmt46kq/2rH2jwx5feTbOE6P6PINVNRJh/9BDWECgYEAsCWcH9D3cI/QDeLG1ao7\nrE2NcknP8N783edM07Z/zxWsIsXhBPY3gjHVz2LDl+QHgPWhGML62M0ja/6SsJW3\nYvLLIc82V7eqcVJTZtaFkuht68qu/Jn1ezbzJMJ4YXDYo1+KFi+2CAGR06QILb+I\nlUtj+/nH3HDQjM4ltYfTPUg=\n-----END PRIVATE KEY-----\n", + "client_email": "foo-email@foo-project.iam.gserviceaccount.com", + "client_id": "100000000000000000001", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/foo-email%40foo-project.iam.gserviceaccount.com" +})"""; + +constexpr std::string_view kAuthorizedUserContents = R"""({ + "type": "authorized_user", + "client_id": "test-client-id.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token" +})"""; + +// The minimum gRPC needs to create file-sourced external account credentials; +// the subject token file is only read when a token is requested. +constexpr std::string_view kExternalAccountContents = R"""({ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/test-pool/providers/test-provider", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": {"file": "/var/run/secrets/test-subject-token"} +})"""; + +std::string WriteTempFile(std::string_view contents) { + std::string const filename = CreateRandomFileName(); + std::ofstream(filename) << contents; + return filename; +} + +// Tests of the library's own ADC lookup pin both of its locations, so a +// developer's real gcloud credentials can never leak into them. +// `AdcEnvironment` points `GOOGLE_APPLICATION_CREDENTIALS` at @p adc_file (or +// unsets it when empty) and the gcloud override at a file that does not exist. +// gRPC's own lookup, used when the option is off, ignores the override; the +// tests that take that path do not depend on what gRPC finds. +struct AdcEnvironment { + explicit AdcEnvironment(std::optional const& adc_file) + : env("GOOGLE_APPLICATION_CREDENTIALS", adc_file), + gcloud("GOOGLE_GCLOUD_ADC_PATH_OVERRIDE", CreateRandomFileName()) {} + ScopedEnvironment env; + ScopedEnvironment gcloud; +}; + +Options TlsOnlyOptions() { + return Options{}.set(true); +} + +/// @test Verify the lookup reads the file named by the environment variable +/// and classifies a service account key. +TEST(UnifiedGrpcCredentialsTest, LookupAdcServiceAccount) { + std::string const filename = WriteTempFile(kServiceAccountContents); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kServiceAccount); + EXPECT_EQ(lookup->path, filename); + EXPECT_EQ(lookup->contents, kServiceAccountContents); + EXPECT_EQ(lookup->type, "service_account"); +} + +/// @test Verify the lookup classifies a gcloud user credentials file. +TEST(UnifiedGrpcCredentialsTest, LookupAdcAuthorizedUser) { + std::string const filename = WriteTempFile(kAuthorizedUserContents); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kAuthorizedUser); + EXPECT_EQ(lookup->type, "authorized_user"); +} + +/// @test Verify the lookup classifies an external account file. +TEST(UnifiedGrpcCredentialsTest, LookupAdcExternalAccount) { + std::string const filename = WriteTempFile(kExternalAccountContents); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kExternalAccount); + EXPECT_EQ(lookup->type, "external_account"); +} + +class UnifiedGrpcCredentialsAdcTypeTest + : public ::testing::TestWithParam> {}; + +/// @test Verify the `type` values that gRPC's Google Default credentials do +/// not support are reported as unsupported, including +/// `external_account_authorized_user`, which the REST credentials accept, an +/// empty `type`, and a missing one, which is reported as unset. +TEST_P(UnifiedGrpcCredentialsAdcTypeTest, LookupAdcUnsupportedType) { + std::optional const& type = GetParam(); + nlohmann::json contents{{"client_id", "test-client-id"}}; + if (type) contents["type"] = *type; + std::string const filename = WriteTempFile(contents.dump()); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kUnsupportedType); + EXPECT_EQ(lookup->type, type); +} + +INSTANTIATE_TEST_SUITE_P( + UnifiedGrpcCredentialsTest, UnifiedGrpcCredentialsAdcTypeTest, + ::testing::Values("external_account_authorized_user", + "impersonated_service_account", "gdch_service_account", + "not-a-known-type", "", std::nullopt), + [](::testing::TestParamInfo> const& info) { + if (!info.param) return std::string{"MissingType"}; + if (info.param->empty()) return std::string{"EmptyType"}; + std::string name = *info.param; + std::replace(name.begin(), name.end(), '-', '_'); + return name; + }); + +/// @test Verify a `type` that is not a string is treated as absent rather than +/// read as a string, which would throw inside the JSON library. +TEST(UnifiedGrpcCredentialsTest, LookupAdcNonStringType) { + std::string const filename = WriteTempFile(R"""({"type": 42})"""); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kUnsupportedType); + EXPECT_EQ(lookup->type, std::nullopt); +} + +struct AdcNonObjectContentsTestCase { + std::string test_name; + std::string contents; +}; + +class UnifiedGrpcCredentialsAdcContentsTest + : public ::testing::TestWithParam {}; + +/// @test Verify that files that do not hold a JSON object are reported as +/// such, rather than as an unsupported type or a crash. The contents are kept, +/// so an empty file is distinguishable from no file at all. +TEST_P(UnifiedGrpcCredentialsAdcContentsTest, LookupAdcNotJsonObject) { + std::string const filename = WriteTempFile(GetParam().contents); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kNotJsonObject); + EXPECT_EQ(lookup->path, filename); + EXPECT_EQ(lookup->contents, GetParam().contents); +} + +INSTANTIATE_TEST_SUITE_P( + UnifiedGrpcCredentialsTest, UnifiedGrpcCredentialsAdcContentsTest, + ::testing::Values(AdcNonObjectContentsTestCase{"InvalidJson", "not json"}, + AdcNonObjectContentsTestCase{"JsonArray", "[]"}, + AdcNonObjectContentsTestCase{"JsonString", + R"("a string")"}, + AdcNonObjectContentsTestCase{"JsonNumber", "42"}, + AdcNonObjectContentsTestCase{"EmptyFile", ""}), + [](::testing::TestParamInfo const& info) { + return info.param.test_name; + }); + +/// @test Verify a configured file that cannot be opened is an error, as in the +/// REST credentials, rather than a silent fallback. +TEST(UnifiedGrpcCredentialsTest, LookupAdcMissingFile) { + std::string const filename = CreateRandomFileName(); + AdcEnvironment adc(filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + EXPECT_THAT(lookup, StatusIs(StatusCode::kUnknown, + HasSubstr("Cannot open credentials file"))); +} + +/// @test Verify the gcloud file is used when the environment variable is not +/// set. +TEST(UnifiedGrpcCredentialsTest, LookupAdcGcloudOverride) { + std::string const filename = WriteTempFile(kServiceAccountContents); + ScopedEnvironment env("GOOGLE_APPLICATION_CREDENTIALS", std::nullopt); + ScopedEnvironment gcloud("GOOGLE_GCLOUD_ADC_PATH_OVERRIDE", filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kServiceAccount); + EXPECT_EQ(lookup->path, filename); +} + +/// @test Verify a missing gcloud file is not an error: the lookup reports that +/// there is no file, which selects Compute Engine credentials. +TEST(UnifiedGrpcCredentialsTest, LookupAdcNoFile) { + AdcEnvironment adc(std::nullopt); + StatusOr lookup = LookupApplicationDefaultCredentials(); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kNone); + EXPECT_EQ(lookup->path, std::nullopt); + EXPECT_EQ(lookup->contents, std::nullopt); +} + +/// @test Verify an empty environment variable is treated as unset, as the +/// REST credentials and gRPC do. +TEST(UnifiedGrpcCredentialsTest, LookupAdcEmptyEnvVarFallsThrough) { + std::string const filename = WriteTempFile(kAuthorizedUserContents); + ScopedEnvironment env("GOOGLE_APPLICATION_CREDENTIALS", ""); + ScopedEnvironment gcloud("GOOGLE_GCLOUD_ADC_PATH_OVERRIDE", filename); + StatusOr lookup = LookupApplicationDefaultCredentials(); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kAuthorizedUser); +} + +/// @test Verify the lookup copes with no home directory at all. Both `HOME` +/// and `APPDATA` are unset so the test is hermetic on every platform. +TEST(UnifiedGrpcCredentialsTest, LookupAdcNoHome) { + ScopedEnvironment env("GOOGLE_APPLICATION_CREDENTIALS", std::nullopt); + ScopedEnvironment gcloud("GOOGLE_GCLOUD_ADC_PATH_OVERRIDE", std::nullopt); + ScopedEnvironment home("HOME", std::nullopt); + ScopedEnvironment appdata("APPDATA", std::nullopt); + StatusOr lookup = LookupApplicationDefaultCredentials(); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kNone); +} + +/// @test Verify that without an override the lookup consults the gcloud file +/// in the home directory, and that its absence means there is no credentials +/// file. `HOME` and `APPDATA` both name a directory that does not exist, so +/// the test is hermetic on every platform. +TEST(UnifiedGrpcCredentialsTest, LookupAdcNoGcloudFileInHome) { + ScopedEnvironment env("GOOGLE_APPLICATION_CREDENTIALS", std::nullopt); + ScopedEnvironment gcloud("GOOGLE_GCLOUD_ADC_PATH_OVERRIDE", std::nullopt); + ScopedEnvironment home("HOME", CreateRandomFileName()); + ScopedEnvironment appdata("APPDATA", CreateRandomFileName()); + StatusOr lookup = LookupApplicationDefaultCredentials(); + ASSERT_STATUS_OK(lookup); + EXPECT_EQ(lookup->source, AdcSource::kNone); +} + +/// @test Verify `GrpcGoogleDefaultTlsOnlyOption` is ignored unless set: with +/// the option false the strategy is `grpc::GoogleDefaultCredentials()`, whose +/// own handling of a missing credentials file falls through to the gcloud file +/// and the metadata server instead of failing, so the strategy is created. +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyFalseIgnoresAdc) { + AdcEnvironment adc(CreateRandomFileName()); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeGoogleDefaultCredentials(), cq, + Options{}.set(false)); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); + EXPECT_THAT(context.credentials(), IsNull()); +} + +/// @test Verify a service account key yields channel-level credentials: no +/// per-call configuration, as with `grpc::GoogleDefaultCredentials()`. +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyServiceAccount) { + std::string const filename = WriteTempFile(kServiceAccountContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); + EXPECT_THAT(context.credentials(), IsNull()); +} + +/// @test Verify gcloud user credentials yield channel-level credentials. +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyAuthorizedUser) { + std::string const filename = WriteTempFile(kAuthorizedUserContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); +} + +/// @test Verify a well-formed external account configuration yields +/// channel-level credentials: TLS composed with gRPC's call credentials, so no +/// per-call configuration is needed. +TEST(UnifiedGrpcCredentialsTest, WithExternalAccountCredentials) { + CompletionQueue cq; + ExternalAccountConfig const creds(std::string(kExternalAccountContents), + Options{}); + std::shared_ptr auth = + CreateAuthenticationStrategy(creds, cq); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); + EXPECT_THAT(context.credentials(), IsNull()); +} + +/// @test Verify an external account file yields channel-level credentials. +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyExternalAccount) { + std::string const filename = WriteTempFile(kExternalAccountContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); +} + +/// @test Verify no credentials file selects Compute Engine credentials without +/// contacting the metadata server, as the REST credentials do. +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyComputeEngineFallback) { + AdcEnvironment adc(std::nullopt); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); +} + +struct UnsupportedTypeTestCase { + std::string test_name; + std::string contents; + std::string expected_type; // As printed in the error message. +}; + +class UnifiedGrpcCredentialsUnsupportedTypeTest + : public ::testing::TestWithParam {}; + +/// @test Verify a type that gRPC's Google Default credentials reject is +/// reported with the same message as the REST credentials: the `type` as +/// found, `()` for an empty string, and `(no type given)` when the member is +/// absent or not a string. +TEST_P(UnifiedGrpcCredentialsUnsupportedTypeTest, + WithDefaultCredentialsTlsOnlyUnsupportedType) { + std::string const filename = WriteTempFile(GetParam().contents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("Unsupported credential type (" + + GetParam().expected_type + ")"))); +} + +INSTANTIATE_TEST_SUITE_P( + UnifiedGrpcCredentialsTest, UnifiedGrpcCredentialsUnsupportedTypeTest, + ::testing::Values( + UnsupportedTypeTestCase{ + "ExternalAccountAuthorizedUser", + R"""({"type": "external_account_authorized_user"})""", + "external_account_authorized_user"}, + UnsupportedTypeTestCase{"MissingType", + R"""({"client_id": "test-client-id"})""", + "no type given"}, + UnsupportedTypeTestCase{"NonStringType", R"""({"type": 42})""", + "no type given"}, + UnsupportedTypeTestCase{"EmptyType", R"""({"type": ""})""", ""}), + [](::testing::TestParamInfo const& info) { + return info.param.test_name; + }); + +/// @test Verify a service account file with a supported `type` but no usable +/// key is an error rather than a null credentials object handed to gRPC. +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyServiceAccountInvalid) { + std::string const filename = + WriteTempFile(R"""({"type": "service_account"})"""); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("grpc::ServiceAccountJWTAccessCredentials"))); +} + +/// @test Verify a user credentials file without a refresh token is an error. +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyAuthorizedUserInvalid) { + std::string const filename = + WriteTempFile(R"""({"type": "authorized_user"})"""); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("grpc::GoogleRefreshTokenCredentials"))); +} + +/// @test Verify an external account file without a credential source is an +/// error. +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyExternalAccountInvalid) { + std::string const filename = + WriteTempFile(R"""({"type": "external_account"})"""); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("grpc::ExternalAccountCredentials"))); +} + +/// @test Verify a file that is not a JSON object is reported as such. +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyNotJsonObject) { + std::string const filename = WriteTempFile("not json"); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("does not contain a JSON object"))); +} + +/// @test Verify a configured file that cannot be opened fails every RPC with +/// the same status as the REST credentials. +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyMissingFile) { + AdcEnvironment adc(CreateRandomFileName()); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy(*MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kUnknown, + HasSubstr("Cannot open credentials file"))); +} + +/// @test Verify the strategy is created when `CARootsFilePathOption` names a +/// readable file. Whether the roots are used is verified by the loopback +/// handshake test below. +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyCustomCACertPath) { + std::string const ca_filename = WriteTempFile(kCACertificate); + std::string const filename = WriteTempFile(kServiceAccountContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions().set(ca_filename)); + (void)std::remove(ca_filename.c_str()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); +} + +/// @test Verify the strategy is created when `CARootsFilePathOption` names a +/// missing file, as it is for the other TLS strategies (`LoadCAInfo()` yields +/// empty roots). +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyMissingCACertFile) { + std::string const filename = WriteTempFile(kServiceAccountContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions().set(CreateRandomFileName())); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + EXPECT_FALSE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); +} + +// A throwaway certificate chain for the loopback TLS server below: a CA and a +// leaf for `localhost`, both EC P-256, valid for 100 years so the test does not +// rot. Generated with +// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes +// -keyout ca.key -out ca.pem -days 36500 -subj "/CN=Test-Only Invalid CA" +// -addext "basicConstraints=critical,CA:TRUE" +// openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes +// -keyout server.key -out server.csr -subj "/CN=localhost" +// openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial +// -out server.pem -days 36500 +// -extfile <(echo "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1") +// The keys were created for this test and are used nowhere else. +constexpr std::string_view kTestCACertificate = R"""(-----BEGIN CERTIFICATE----- +MIIB3jCCAYOgAwIBAgIUGgdWlyrdqVC6SB7IqHHL4f/EHGcwCgYIKoZIzj0EAwIw +OzEaMBgGA1UECgwRVGVzdC1Pbmx5IEludmFsaWQxHTAbBgNVBAMMFFRlc3QtT25s +eSBJbnZhbGlkIENBMCAXDTI2MTAyODA5MjkwNFoYDzIxMjYxMDA0MDkyOTA0WjA7 +MRowGAYDVQQKDBFUZXN0LU9ubHkgSW52YWxpZDEdMBsGA1UEAwwUVGVzdC1Pbmx5 +IEludmFsaWQgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATWO1bj6lDqEdh8 +9PlqSV7bMKm3P4ixev/nfnumdwn2lQhx/Nckk+94jKXEOMUBv0Ilvo2sNgVmOcUZ +vqICpJeio2MwYTAdBgNVHQ4EFgQU0AIjsZUDLU/twvWi/omRpZyaCa8wHwYDVR0j +BBgwFoAU0AIjsZUDLU/twvWi/omRpZyaCa8wDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSQAwRgIhAI3eAc1hdKSyMr0HIeFXCQA5 ++GRlfoR87VmVxat53/MwAiEAq5V/wLaYb6DCprrb2K4+lcCb28hWfeCyq8ui8Coq ++D4= +-----END CERTIFICATE----- +)"""; + +constexpr std::string_view kTestServerCertificate = + R"""(-----BEGIN CERTIFICATE----- +MIICFTCCAbqgAwIBAgIUGr/yJFruXynjDkWbnUnnjA+A454wCgYIKoZIzj0EAwIw +OzEaMBgGA1UECgwRVGVzdC1Pbmx5IEludmFsaWQxHTAbBgNVBAMMFFRlc3QtT25s +eSBJbnZhbGlkIENBMCAXDTI2MTAyODA5MjkwNFoYDzIxMjYxMDA0MDkyOTA0WjAw +MRowGAYDVQQKDBFUZXN0LU9ubHkgSW52YWxpZDESMBAGA1UEAwwJbG9jYWxob3N0 +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGTEMyVXEeMa0qGVchcNT1OhG62AG +bUZuZzCY9UjCIUurZ6UhyWUCcgsygBQRgtqYqF+2ym51wChnxjyaMgY1+qOBpDCB +oTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEF +BQcDATAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAA +AAEwHQYDVR0OBBYEFMHpLnTOgcbHS4L3lhwvtg3pCOv+MB8GA1UdIwQYMBaAFNAC +I7GVAy1P7cL1ov6JkaWcmgmvMAoGCCqGSM49BAMCA0kAMEYCIQDui5YFlxXHmreM +IEQ0ff+Evhh2GfW1GYN3xi6P6v1cmwIhAO9Konn0DUEQ4BfOUn6sz9VI4NSbS3Al +2tCUCwuh8AbG +-----END CERTIFICATE----- +)"""; + +constexpr std::string_view kTestServerKey = R"""(-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4q4eba/bxYvpXw3S +oWV15dRs6eZBBi5mWfwgFC8f8jmhRANCAAQZMQzJVcR4xrSoZVyFw1PU6EbrYAZt +Rm5nMJj1SMIhS6tnpSHJZQJyCzKAFBGC2pioX7bKbnXAKGfGPJoyBjX6 +-----END PRIVATE KEY----- +)"""; + +// Records the `authorization` metadata of the last `GenerateAccessToken()` +// call, so a test can tell whether call credentials were attached to the +// channel. `IAMCredentials` is used only because `grpc_utils` already links +// its proto; any unary RPC would do. +class RecordingIamCredentialsService + : public google::iam::credentials::v1::IAMCredentials::Service { + public: + grpc::Status GenerateAccessToken( + grpc::ServerContext* context, + google::iam::credentials::v1::GenerateAccessTokenRequest const*, + google::iam::credentials::v1::GenerateAccessTokenResponse*) override { + std::multimap const& metadata = + context->client_metadata(); + auto const it = metadata.find("authorization"); + std::scoped_lock lk(mu_); + authorization_ = it == metadata.end() + ? std::nullopt + : std::make_optional(std::string(it->second.data(), + it->second.size())); + return grpc::Status::OK; + } + + std::optional authorization() const { + std::scoped_lock lk(mu_); + return authorization_; + } + + private: + mutable std::mutex mu_; + std::optional authorization_; +}; + +// A TLS server on a loopback port that presents `kTestServerCertificate`. +class TlsTestServer { + public: + TlsTestServer() { + grpc::SslServerCredentialsOptions ssl_options; + ssl_options.pem_key_cert_pairs.push_back( + {std::string(kTestServerKey), std::string(kTestServerCertificate)}); + grpc::ServerBuilder builder; + builder.AddListeningPort("localhost:0", + grpc::SslServerCredentials(ssl_options), &port_); + builder.RegisterService(&service_); + server_ = builder.BuildAndStart(); + } + ~TlsTestServer() { server_->Shutdown(); } + + std::string endpoint() const { return "localhost:" + std::to_string(port_); } + RecordingIamCredentialsService const& service() const { return service_; } + + private: + RecordingIamCredentialsService service_; + int port_ = 0; + std::unique_ptr server_; +}; + +// Sends one empty RPC to @p server over a channel created by @p auth; only +// the TLS handshake and channel-level metadata are under test. +Status CallOverTls(GrpcAuthenticationStrategy& auth, + TlsTestServer const& server, grpc::ClientContext& context) { + std::shared_ptr channel = + auth.CreateChannel(server.endpoint(), grpc::ChannelArguments{}); + std::unique_ptr stub = + google::iam::credentials::v1::IAMCredentials::NewStub(channel); + Status configured = auth.ConfigureContext(context); + if (!configured.ok()) return configured; + google::iam::credentials::v1::GenerateAccessTokenRequest request; + google::iam::credentials::v1::GenerateAccessTokenResponse response; + return MakeStatusFromRpcError( + stub->GenerateAccessToken(&context, request, &response)); +} + +/// @test Verify the strategy uses TLS with the roots from +/// `CARootsFilePathOption` and attaches the ADC call credentials at the +/// channel level: an RPC to a loopback server presenting a test-only +/// certificate completes, and the server receives a bearer token. +/// `grpc::GoogleDefaultCredentials()` cannot pass this test because it ignores +/// `CARootsFilePathOption` (see the next test). +TEST(UnifiedGrpcCredentialsTest, WithDefaultCredentialsTlsOnlyHandshakes) { + TlsTestServer server; + std::string const ca_filename = WriteTempFile(kTestCACertificate); + std::string const filename = WriteTempFile(kServiceAccountContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeGoogleDefaultCredentials(), cq, + TlsOnlyOptions().set(ca_filename)); + (void)std::remove(ca_filename.c_str()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + // Connecting to ourselves can be slow under CI load; wait for the channel + // rather than failing fast, with a generous deadline. + context.set_wait_for_ready(true); + context.set_deadline(std::chrono::system_clock::now() + + std::chrono::seconds(30)); + EXPECT_THAT(CallOverTls(*auth, server, context), IsOk()); + EXPECT_THAT(server.service().authorization(), + Optional(StartsWith("Bearer "))); +} + +/// @test Verify the control for the previous test: with the option false the +/// strategy is `grpc::GoogleDefaultCredentials()`, which ignores +/// `CARootsFilePathOption`, so the loopback server's certificate is rejected +/// and the RPC fails before reaching the service. +TEST(UnifiedGrpcCredentialsTest, + WithDefaultCredentialsTlsOnlyFalseIgnoresCustomRoots) { + TlsTestServer server; + std::string const ca_filename = WriteTempFile(kTestCACertificate); + std::string const filename = WriteTempFile(kServiceAccountContents); + AdcEnvironment adc(filename); + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeGoogleDefaultCredentials(), cq, + Options{} + .set(false) + .set(ca_filename)); + (void)std::remove(ca_filename.c_str()); + (void)std::remove(filename.c_str()); + ASSERT_THAT(auth, NotNull()); + grpc::ClientContext context; + // Fail fast: a rejected handshake puts the channel in TRANSIENT_FAILURE, and + // waiting for it to become ready would only run into the deadline. + context.set_deadline(std::chrono::system_clock::now() + + std::chrono::seconds(30)); + EXPECT_THAT(CallOverTls(*auth, server, context), + StatusIs(StatusCode::kUnavailable)); + EXPECT_THAT(server.service().authorization(), Eq(std::nullopt)); +} + +/// @test Verify the option only affects Google Default credentials: an +/// explicit credential type keeps its per-call strategy. +TEST(UnifiedGrpcCredentialsTest, + WithAccessTokenCredentialsIgnoresTlsOnlyOption) { + CompletionQueue cq; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeAccessTokenCredentials( + "test-token", + std::chrono::system_clock::now() + std::chrono::minutes(15)), + cq, TlsOnlyOptions()); + ASSERT_THAT(auth, NotNull()); + EXPECT_TRUE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), IsOk()); + EXPECT_THAT(context.credentials(), NotNull()); +} + +/// @test Verify the option reaches the base credentials of impersonated +/// credentials, which are built with the same options for the IAM channel: a +/// missing credentials file surfaces as the TLS strategy's error instead of +/// being handled by `grpc::GoogleDefaultCredentials()`. The error is produced +/// before any RPC is attempted; the completion queue only delivers it, so the +/// test needs no network. +TEST(UnifiedGrpcCredentialsTest, + WithImpersonateServiceAccountCredentialsTlsOnlyBaseCredentials) { + AdcEnvironment adc(CreateRandomFileName()); + AutomaticallyCreatedBackgroundThreads background; + std::shared_ptr auth = + CreateAuthenticationStrategy( + *MakeImpersonateServiceAccountCredentials( + MakeGoogleDefaultCredentials(), + "test-only@test-project.iam.gserviceaccount.com"), + background.cq(), TlsOnlyOptions()); + ASSERT_THAT(auth, NotNull()); + EXPECT_TRUE(auth->RequiresConfigureContext()); + grpc::ClientContext context; + EXPECT_THAT(auth->ConfigureContext(context), + StatusIs(StatusCode::kUnknown, + HasSubstr("Cannot open credentials file"))); +} + } // namespace } // namespace internal GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END diff --git a/google/cloud/storage/grpc_plugin.h b/google/cloud/storage/grpc_plugin.h index 0d403b59e2b9f..57cf35fc59622 100644 --- a/google/cloud/storage/grpc_plugin.h +++ b/google/cloud/storage/grpc_plugin.h @@ -158,6 +158,19 @@ struct GrpcMetricsExcludedLabelsOption { * Interconnect endpoint itself gets the Interconnect `:authority` by default * even when the variable is `"false"`. * + * @par Credentials + * Interconnect hosts are outside Google Cloud, so the ALTS transport security + * that `grpc::GoogleDefaultCredentials()` would select for DirectPath is not + * available. On Interconnect channels the default credentials + * (`MakeGoogleDefaultCredentials()`) therefore use TLS composed with the + * [Application Default Credentials] attached to the channel. Explicit + * credentials such as `MakeServiceAccountCredentials()` already use TLS and + * are unchanged. An application that supplies raw gRPC credentials through + * `GrpcCredentialOption` must not use ALTS-based credentials here. Application + * Default Credentials must be configured even when explicit credentials are + * used, because gRPC's `google-c2p` resolver uses them for its own connection + * to the control plane. + * * @par Example: Enable DirectPath over Interconnect * @code * namespace gcs_ex = google::cloud::storage_experimental; @@ -166,6 +179,8 @@ struct GrpcMetricsExcludedLabelsOption { * .set(true)); * @endcode * + * [Application Default Credentials]: + * https://cloud.google.com/docs/authentication/application-default-credentials * [Cloud Interconnect]: * https://cloud.google.com/network-connectivity/docs/interconnect */ diff --git a/google/cloud/storage/internal/grpc/default_options.cc b/google/cloud/storage/internal/grpc/default_options.cc index ba0a40317ce4f..6011eef6406ae 100644 --- a/google/cloud/storage/internal/grpc/default_options.cc +++ b/google/cloud/storage/internal/grpc/default_options.cc @@ -22,6 +22,7 @@ #include "google/cloud/internal/getenv.h" #include "google/cloud/internal/populate_common_options.h" #include "google/cloud/internal/service_endpoint.h" +#include "google/cloud/internal/unified_grpc_credentials.h" #include "google/cloud/universe_domain_options.h" #include "absl/strings/match.h" #include "absl/strings/str_cat.h" @@ -195,12 +196,23 @@ Options DefaultOptionsGrpc( // Set `AuthorityOption` explicitly here because `MergeOptions()` below would // otherwise default it to `storage.googleapis.com`. std::string const& endpoint = options.get(); - if (!options.has() && grpc_supports_force_xds && - DetectTransportType(endpoint) == TransportType::kDirectPathInterconnect && + bool const interconnect_endpoint = + grpc_supports_force_xds && + DetectTransportType(endpoint) == TransportType::kDirectPathInterconnect; + if (!options.has() && interconnect_endpoint && TargetsDirectPathInterconnectHost(endpoint)) { options.set(std::string(kDirectPathInterconnectHost)); } + // Interconnect clients run outside Google Cloud, where ALTS is unavailable, + // yet `grpc::GoogleDefaultCredentials()` selects ALTS for the (non-CFE) xDS + // cluster behind a `force-xds` target. Have default credentials use TLS plus + // the Application Default Credentials there instead. Unlike the authority + // above this applies to any `force-xds` host: the choice of transport + // security does not depend on which host is dialed. + options.set( + interconnect_endpoint); + options = google::cloud::internal::MergeOptions( std::move(options), Options{} diff --git a/google/cloud/storage/internal/grpc/default_options_test.cc b/google/cloud/storage/internal/grpc/default_options_test.cc index a618de3d197ae..186210d5e22d4 100644 --- a/google/cloud/storage/internal/grpc/default_options_test.cc +++ b/google/cloud/storage/internal/grpc/default_options_test.cc @@ -18,6 +18,7 @@ #include "google/cloud/credentials.h" #include "google/cloud/grpc_options.h" #include "google/cloud/internal/detect_gcp.h" +#include "google/cloud/internal/unified_grpc_credentials.h" #include "google/cloud/testing_util/chrono_output.h" #include "google/cloud/testing_util/scoped_environment.h" #include "google/cloud/universe_domain_options.h" @@ -42,6 +43,7 @@ class MockGcpDetector : public google::cloud::internal::GcpDetector { MOCK_METHOD(bool, IsGoogleCloudServerless, (), (override)); }; +using ::google::cloud::internal::GrpcGoogleDefaultTlsOnlyOption; using ::google::cloud::testing_util::ScopedEnvironment; using ::testing::Return; @@ -87,6 +89,7 @@ TEST(DefaultOptionsGrpc, DefaultEndpointsCloudPath) { auto options = DefaultOptionsGrpc(Options{}, mock_detector); EXPECT_EQ(options.get(), "storage.googleapis.com"); EXPECT_EQ(options.get(), "storage.googleapis.com"); + EXPECT_FALSE(options.get()); } TEST(DefaultOptionsGrpc, DefaultEndpointsDirectPath) { @@ -99,6 +102,9 @@ TEST(DefaultOptionsGrpc, DefaultEndpointsDirectPath) { EXPECT_EQ(options.get(), "google-c2p:///storage.googleapis.com"); EXPECT_EQ(options.get(), "storage.googleapis.com"); + // Regular DirectPath relies on ALTS, which only Google Default credentials + // select. + EXPECT_FALSE(options.get()); } /// @test Verify the option defaults to disabled, and that the resolved default @@ -119,10 +125,12 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectDefault) { options.get()); EXPECT_EQ(options.get(), "storage.googleapis.com"); EXPECT_EQ(options.get(), "storage.googleapis.com"); + EXPECT_FALSE(options.get()); } /// @test Verify enabling the option off-GCE targets the DirectPath resolver, -/// sets the Interconnect `:authority`, and uses a single channel. +/// sets the Interconnect `:authority`, uses a single channel, and switches the +/// default credentials to TLS. TEST(DefaultOptionsGrpc, DirectPathOverInterconnectOption) { ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", {}); @@ -140,6 +148,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectOption) { // `:authority`. EXPECT_EQ(options.get(), "storage-direct.googleapis.com"); EXPECT_EQ(options.get(), 1); + EXPECT_TRUE(options.get()); } /// @test Verify the environment variable enables the feature when the option is @@ -157,6 +166,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectEnvVar) { "google-c2p:///storage-direct.googleapis.com?force-xds"); EXPECT_EQ(options.get(), "storage-direct.googleapis.com"); EXPECT_EQ(options.get(), 1); + EXPECT_TRUE(options.get()); } /// @test Verify explicitly disabling the option off-GCE keeps the client on @@ -177,6 +187,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectDisabled) { EXPECT_EQ(options.get(), "storage.googleapis.com"); EXPECT_EQ(options.get(), "storage.googleapis.com"); EXPECT_GE(options.get(), 4); + EXPECT_FALSE(options.get()); } /// @test Verify a caller-supplied endpoint wins over the DirectPath target, so @@ -194,6 +205,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectUserEndpointOverride) { mock_detector); EXPECT_EQ(options.get(), "custom-endpoint"); EXPECT_EQ(options.get(), "custom-authority"); + EXPECT_FALSE(options.get()); } /// @test Verify a caller-supplied universe domain wins over the DirectPath @@ -210,6 +222,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectUniverseDomainOverride) { mock_detector); EXPECT_EQ(options.get(), "storage.my-ud.net"); EXPECT_EQ(options.get(), "storage.my-ud.net"); + EXPECT_FALSE(options.get()); } /// @test Verify `GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT=true` @@ -231,6 +244,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectEnvVarOverridesOption) { "google-c2p:///storage-direct.googleapis.com?force-xds"); EXPECT_EQ(options.get(), "storage-direct.googleapis.com"); EXPECT_EQ(options.get(), 1); + EXPECT_TRUE(options.get()); } /// @test Verify `GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT=false` @@ -254,10 +268,13 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectEnvVarFalseOverridesOption) { EXPECT_EQ(options.get(), "storage.googleapis.com"); EXPECT_EQ(options.get(), "storage.googleapis.com"); EXPECT_GE(options.get(), 4); + EXPECT_FALSE(options.get()); } /// @test Verify a caller-supplied authority is preserved when the DirectPath /// target is selected; `storage-direct.googleapis.com` is only the default. +/// The credentials still switch to TLS: the authority does not change what +/// transport security the Interconnect endpoint accepts. TEST(DefaultOptionsGrpc, DirectPathOverInterconnectPreservesCustomAuthority) { ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", {}); @@ -274,6 +291,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectPreservesCustomAuthority) { "google-c2p:///storage-direct.googleapis.com?force-xds"); EXPECT_EQ(options.get(), "custom-authority"); EXPECT_EQ(options.get(), 1); + EXPECT_TRUE(options.get()); } /// @test Verify an application-supplied Interconnect endpoint also gets the @@ -294,11 +312,13 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectExplicitEndpoint) { "google-c2p:///storage-direct.googleapis.com?force-xds"); EXPECT_EQ(options.get(), "storage-direct.googleapis.com"); EXPECT_EQ(options.get(), 1); + EXPECT_TRUE(options.get()); } /// @test Verify an application-supplied Interconnect endpoint keeps the -/// standard `:authority` when gRPC ignores `force-xds`, as the channel then -/// uses regular DirectPath or CloudPath rather than Interconnect. +/// standard `:authority` and the standard credentials when gRPC ignores +/// `force-xds`, as the channel then uses regular DirectPath or CloudPath +/// rather than Interconnect. TEST(DefaultOptionsGrpc, DirectPathOverInterconnectExplicitEndpointUnsupportedGrpc) { ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", @@ -312,11 +332,14 @@ TEST(DefaultOptionsGrpc, "google-c2p:///storage-direct.googleapis.com?force-xds"), mock_detector, /*grpc_supports_force_xds=*/false); EXPECT_EQ(options.get(), "storage.googleapis.com"); + EXPECT_FALSE(options.get()); } /// @test Verify an application-supplied `force-xds` endpoint for any host other /// than `storage-direct.googleapis.com` keeps the standard `:authority`. The -/// service recognizes the Interconnect authority only for that host. +/// service recognizes the Interconnect authority only for that host. The +/// credentials still switch to TLS, because ALTS is unavailable to Interconnect +/// clients whatever host they dial. TEST(DefaultOptionsGrpc, DirectPathOverInterconnectExplicitEndpointOtherHost) { ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", {}); @@ -331,6 +354,7 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectExplicitEndpointOtherHost) { EXPECT_EQ(options.get(), "google-c2p:///storage.googleapis.com?force-xds"); EXPECT_EQ(options.get(), "storage.googleapis.com"); + EXPECT_TRUE(options.get()); } struct InterconnectHostDelimiterTestCase { @@ -405,13 +429,14 @@ TEST(DefaultOptionsGrpc, mock_detector, /*grpc_supports_force_xds=*/true); EXPECT_EQ(options.get(), "storage-direct.googleapis.com"); EXPECT_EQ(options.get(), 1); + EXPECT_TRUE(options.get()); } /// @test Verify `GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT=false` /// leaves an application-supplied Interconnect endpoint, and therefore its -/// `:authority`, unchanged. The variable and the option govern only the -/// endpoint the library selects, and the authority must match the endpoint -/// actually in use. +/// `:authority` and credentials, unchanged. The variable and the option govern +/// only the endpoint the library selects, and the authority and credentials +/// must match the endpoint actually in use. TEST(DefaultOptionsGrpc, DirectPathOverInterconnectExplicitEndpointEnvVarFalse) { ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", @@ -429,6 +454,7 @@ TEST(DefaultOptionsGrpc, EXPECT_EQ(options.get(), "google-c2p:///storage-direct.googleapis.com?force-xds"); EXPECT_EQ(options.get(), "storage-direct.googleapis.com"); + EXPECT_TRUE(options.get()); } /// @test Verify universe domain precedence also holds when the domain comes @@ -445,6 +471,45 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectUniverseDomainEnvVar) { mock_detector); EXPECT_EQ(options.get(), "storage.my-ud.net"); EXPECT_EQ(options.get(), "storage.my-ud.net"); + EXPECT_FALSE(options.get()); +} + +/// @test Verify the testbench endpoint wins over the Interconnect option and +/// keeps the (insecure) testbench credentials. +TEST(DefaultOptionsGrpc, DirectPathOverInterconnectTestbenchOverride) { + ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", + {}); + ScopedEnvironment testbench( + "CLOUD_STORAGE_EXPERIMENTAL_GRPC_TESTBENCH_ENDPOINT", "localhost:9999"); + auto mock_detector = std::make_shared(); + EXPECT_CALL(*mock_detector, IsGoogleCloudBios()).Times(0); + EXPECT_CALL(*mock_detector, IsGoogleCloudServerless()).Times(0); + + Options const options = DefaultOptionsGrpc( + Options{}.set( + true), + mock_detector, /*grpc_supports_force_xds=*/true); + EXPECT_EQ(options.get(), "localhost:9999"); + EXPECT_FALSE(options.get()); +} + +/// @test Verify the credentials option is recomputed from the effective +/// endpoint rather than inherited from the input options, so a value carried +/// over from another client cannot select TLS on a CloudPath endpoint. +TEST(DefaultOptionsGrpc, DirectPathOverInterconnectTlsOnlyRecomputed) { + ScopedEnvironment env("GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS_OVER_INTERCONNECT", + {}); + auto mock_detector = std::make_shared(); + EXPECT_CALL(*mock_detector, IsGoogleCloudBios()) + .WillRepeatedly(Return(false)); + EXPECT_CALL(*mock_detector, IsGoogleCloudServerless()) + .WillRepeatedly(Return(false)); + + Options const options = DefaultOptionsGrpc( + Options{}.set(true), mock_detector, + /*grpc_supports_force_xds=*/true); + EXPECT_EQ(options.get(), "storage.googleapis.com"); + EXPECT_FALSE(options.get()); } class DirectPathOverInterconnectInvalidEnvVarTest @@ -480,6 +545,7 @@ TEST_P(DirectPathOverInterconnectInvalidEnvVarTest, InvalidValueIgnored) { EXPECT_EQ(options.get(), configured ? "google-c2p:///storage-direct.googleapis.com?force-xds" : "storage.googleapis.com"); + EXPECT_EQ(options.get(), configured); } INSTANTIATE_TEST_SUITE_P( @@ -529,6 +595,7 @@ TEST_P(DirectPathOverInterconnectUnsupportedGrpcTest, FallsBackToDefaults) { EXPECT_TRUE( options.get()); EXPECT_EQ(options.get(), "storage.googleapis.com"); + EXPECT_FALSE(options.get()); if (test_case.on_gce) { EXPECT_EQ(options.get(), "google-c2p:///storage.googleapis.com"); @@ -661,6 +728,8 @@ TEST(DefaultOptionsGrpc, DirectPathOverInterconnectFollowsGrpcVersion) { EXPECT_EQ(options.get(), GrpcForceXdsIsSupported() ? "storage-direct.googleapis.com" : "storage.googleapis.com"); + EXPECT_EQ(options.get(), + GrpcForceXdsIsSupported()); } TEST(DefaultOptionsGrpc, MetricsDisabled) {