diff --git a/google/cloud/internal/oauth2_external_account_credentials.cc b/google/cloud/internal/oauth2_external_account_credentials.cc index 84513772cda49..d41f50fa3c973 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.cc +++ b/google/cloud/internal/oauth2_external_account_credentials.cc @@ -13,6 +13,7 @@ // limitations under the License. #include "google/cloud/internal/oauth2_external_account_credentials.h" +#include "google/cloud/internal/base64_transforms.h" #include "google/cloud/internal/external_account_token_source_aws.h" #include "google/cloud/internal/external_account_token_source_file.h" #include "google/cloud/internal/external_account_token_source_url.h" @@ -26,6 +27,7 @@ #include "absl/strings/str_cat.h" #include "absl/strings/str_split.h" #include +#include #include namespace google { @@ -151,11 +153,48 @@ StatusOr ParseExternalAccountConfiguration( } auto type = ValidateStringField(json, "type", "credentials-file", ec); if (!type) return std::move(type).status(); - if (*type != "external_account") { + if (*type != "external_account" && + *type != "external_account_authorized_user") { return InvalidArgumentError( "mismatched type (" + *type + ") in external account configuration", GCP_ERROR_INFO().WithContext(ec)); } + auto token_url = + ValidateStringField(json, "token_url", "credentials-file", ec); + if (!token_url) return std::move(token_url).status(); + auto universe_domain = GetUniverseDomainFromCredentialsJson(json); + if (!universe_domain) return std::move(universe_domain).status(); + + if (*type == "external_account_authorized_user") { + StatusOr client_id = + ValidateStringField(json, "client_id", "credentials-file", ec); + if (!client_id) return std::move(client_id).status(); + StatusOr client_secret = + ValidateStringField(json, "client_secret", "credentials-file", ec); + if (!client_secret) return std::move(client_secret).status(); + StatusOr refresh_token = + ValidateStringField(json, "refresh_token", "credentials-file", ec); + if (!refresh_token) return std::move(refresh_token).status(); + StatusOr audience = + ValidateStringField(json, "audience", "credentials-file", "", ec); + if (!audience) return std::move(audience).status(); + std::variant + identity_federation = IdentityFederationFromAudience(*audience); + + return ExternalAccountInfo{ + *std::move(audience), + {}, + *std::move(token_url), + {}, + std::nullopt, + *std::move(universe_domain), + std::nullopt, + std::move(identity_federation), + ExternalAccountAuthorizedUserInfo{*std::move(client_id), + *std::move(client_secret), + *std::move(refresh_token)}}; + } auto audience = ValidateStringField(json, "audience", "credentials-file", ec); if (!audience) return std::move(audience).status(); @@ -164,11 +203,6 @@ StatusOr ParseExternalAccountConfiguration( auto subject_token_type = ValidateStringField(json, "subject_token_type", "credentials-file", ec); if (!subject_token_type) return std::move(subject_token_type).status(); - auto token_url = - ValidateStringField(json, "token_url", "credentials-file", ec); - if (!token_url) return std::move(token_url).status(); - auto universe_domain = GetUniverseDomainFromCredentialsJson(json); - if (!universe_domain) return std::move(universe_domain).status(); auto credential_source = json.find("credential_source"); if (credential_source == json.end()) { @@ -218,6 +252,9 @@ ExternalAccountCredentials::ExternalAccountCredentials( StatusOr ExternalAccountCredentials::GetToken( std::chrono::system_clock::time_point tp) { + if (info_.authorized_user_info.has_value()) { + return GetTokenAuthorizedUser(tp); + } auto subject_token = (info_.token_source)(client_factory_, options_); if (!subject_token) return std::move(subject_token).status(); @@ -334,6 +371,65 @@ StatusOr ExternalAccountCredentials::GetTokenImpersonation( return ParseGenerateAccessTokenResponse(**response, ec); } +StatusOr ExternalAccountCredentials::GetTokenAuthorizedUser( + std::chrono::system_clock::time_point tp) { + std::string current_refresh_token; + { + std::lock_guard lk(mu_); + current_refresh_token = info_.authorized_user_info->refresh_token; + } + internal::Base64Encoder encoder; + for (unsigned char const c : + absl::StrCat(info_.authorized_user_info->client_id, ":", + info_.authorized_user_info->client_secret)) { + encoder.PushBack(c); + } + rest_internal::RestRequest request = + rest_internal::RestRequest(info_.token_url) + .AddHeader("content-type", "application/x-www-form-urlencoded") + .AddHeader("Authorization", + absl::StrCat("Basic ", std::move(encoder).FlushAndPad())); + + std::vector> form_data = { + {"grant_type", "refresh_token"}, + {"refresh_token", std::move(current_refresh_token)}, + }; + + std::unique_ptr client = client_factory_(options_); + rest_internal::RestContext unused; + StatusOr> response = + client->Post(unused, request, form_data); + if (!response) return std::move(response).status(); + if (IsHttpError(**response)) return AsStatus(std::move(**response)); + StatusOr payload = + rest_internal::ReadAll(std::move(**response).ExtractPayload()); + if (!payload) return std::move(payload).status(); + + internal::ErrorContext const ec( + internal::ErrorContext::Container{{"token_url", info_.token_url}}); + + nlohmann::json access = nlohmann::json::parse(*payload, nullptr, false); + if (!access.is_object()) { + return InvalidArgumentError( + "token refresh response cannot be parsed as JSON object", + GCP_ERROR_INFO().WithContext(ec)); + } + StatusOr token = + ValidateStringField(access, "access_token", "token-refresh-response", ec); + if (!token) return std::move(token).status(); + StatusOr expires_in = + ValidateIntField(access, "expires_in", "token-refresh-response", ec); + if (!expires_in) return std::move(expires_in).status(); + StatusOr new_refresh_token = ValidateStringField( + access, "refresh_token", "token-refresh-response", "", ec); + if (!new_refresh_token) return std::move(new_refresh_token).status(); + if (!new_refresh_token->empty()) { + std::lock_guard lk(mu_); + info_.authorized_user_info->refresh_token = *std::move(new_refresh_token); + } + return AccessToken{*std::move(token), tp + std::chrono::seconds(*expires_in)}; +} + GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal } // namespace cloud diff --git a/google/cloud/internal/oauth2_external_account_credentials.h b/google/cloud/internal/oauth2_external_account_credentials.h index b3e20ebeb0722..f7a912ac18553 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.h +++ b/google/cloud/internal/oauth2_external_account_credentials.h @@ -24,6 +24,7 @@ #include #include #include +#include #include namespace google { @@ -67,6 +68,12 @@ struct WorkloadIdentityFederationInfo { std::string pool_id; }; +struct ExternalAccountAuthorizedUserInfo { + std::string client_id; + std::string client_secret; + std::string refresh_token; +}; + /** * An external account configuration. * @@ -84,6 +91,8 @@ struct ExternalAccountInfo { std::variant identity_federation_info; + std::optional authorized_user_info = + std::nullopt; bool IsWorkforceIdentityFederation() const; bool IsWorkloadIdentityFederation() const; }; @@ -115,7 +124,10 @@ class ExternalAccountCredentials : public oauth2_internal::Credentials { private: StatusOr GetTokenImpersonation(std::string const& access_token, internal::ErrorContext const& ec); + StatusOr GetTokenAuthorizedUser( + std::chrono::system_clock::time_point tp); + mutable std::mutex mu_; ExternalAccountInfo info_; HttpClientFactory client_factory_; Options options_; diff --git a/google/cloud/internal/oauth2_external_account_credentials_test.cc b/google/cloud/internal/oauth2_external_account_credentials_test.cc index aaf5e18f0dea6..7d294597a177f 100644 --- a/google/cloud/internal/oauth2_external_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_external_account_credentials_test.cc @@ -1361,6 +1361,293 @@ TEST(ExternalAccount, InvalidExpiresIn) { EXPECT_THAT(access_token.status().error_info().domain(), "gcloud-cpp"); } +TEST(ExternalAccountAuthorizedUser, ParseSuccess) { + auto constexpr kUniverseDomain = "my-domain.net"; + auto const configuration = nlohmann::json{ + {"type", "external_account_authorized_user"}, + {"audience", + "//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/" + "provider-id"}, + {"client_id", "test-client-id"}, + {"client_secret", "test-client-secret"}, + {"refresh_token", "test-refresh-token"}, + {"token_url", "https://sts.example.com/v1/oauthtoken"}, + {"token_info_url", "https://sts.example.com/v1/introspect"}, + {"universe_domain", kUniverseDomain}, + }; + auto ec = internal::ErrorContext( + {{"program", "test"}, {"full-configuration", configuration.dump()}}); + auto const actual = + ParseExternalAccountConfiguration(configuration.dump(), ec); + ASSERT_STATUS_OK(actual); + ASSERT_TRUE(actual->authorized_user_info.has_value()); + EXPECT_EQ(actual->authorized_user_info->client_id, "test-client-id"); + EXPECT_EQ(actual->authorized_user_info->client_secret, "test-client-secret"); + EXPECT_EQ(actual->authorized_user_info->refresh_token, "test-refresh-token"); + EXPECT_EQ(actual->token_url, "https://sts.example.com/v1/oauthtoken"); + EXPECT_EQ(actual->universe_domain, kUniverseDomain); + EXPECT_THAT(actual->identity_federation_info, + VariantWith( + WorkforceIdentityIs("pool-id"))); +} + +TEST(ExternalAccountAuthorizedUser, ParseMissingUniverseDomain) { + auto const configuration = nlohmann::json{ + {"type", "external_account_authorized_user"}, + {"client_id", "test-client-id"}, + {"client_secret", "test-client-secret"}, + {"refresh_token", "test-refresh-token"}, + {"token_url", "https://sts.example.com/v1/oauthtoken"}, + }; + auto ec = internal::ErrorContext( + {{"program", "test"}, {"full-configuration", configuration.dump()}}); + auto const actual = + ParseExternalAccountConfiguration(configuration.dump(), ec); + ASSERT_STATUS_OK(actual); + EXPECT_EQ(actual->universe_domain, "googleapis.com"); +} + +TEST(ExternalAccountAuthorizedUser, ParseErrors) { + auto const valid = nlohmann::json{ + {"type", "external_account_authorized_user"}, + {"client_id", "test-client-id"}, + {"client_secret", "test-client-secret"}, + {"refresh_token", "test-refresh-token"}, + {"token_url", "https://sts.example.com/v1/oauthtoken"}, + }; + auto ec = internal::ErrorContext( + {{"program", "test"}, {"full-configuration", valid.dump()}}); + + for (auto const* field : + {"type", "client_id", "client_secret", "refresh_token", "token_url"}) { + auto missing = valid; + missing.erase(field); + EXPECT_THAT( + ParseExternalAccountConfiguration(missing.dump(), ec), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr(absl::StrCat("cannot find `", field, "` field")))); + + auto invalid_type = valid; + invalid_type[field] = true; + EXPECT_THAT(ParseExternalAccountConfiguration(invalid_type.dump(), ec), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr(absl::StrCat("invalid type for `", field, + "` field")))); + } + + { + auto invalid_audience = valid; + invalid_audience["audience"] = true; + EXPECT_THAT(ParseExternalAccountConfiguration(invalid_audience.dump(), ec), + StatusIs(StatusCode::kInvalidArgument, + HasSubstr("invalid type for `audience` field"))); + } + + { + auto empty_ud = valid; + empty_ud["universe_domain"] = ""; + EXPECT_THAT( + ParseExternalAccountConfiguration(empty_ud.dump(), ec), + StatusIs( + StatusCode::kInvalidArgument, + HasSubstr( + "universe_domain field in credentials file cannot be empty"))); + } +} + +TEST(ExternalAccountAuthorizedUser, Working) { + auto const test_url = std::string{"https://sts.example.com/v1/oauthtoken"}; + auto const expected_access_token = std::string{"test-access-token"}; + auto const expected_expires_in = std::chrono::seconds(3456); + auto const json_response = nlohmann::json{ + {"access_token", expected_access_token}, + {"expires_in", expected_expires_in.count()}, + {"token_type", "Bearer"}, + }; + auto const info = ExternalAccountInfo{ + {}, + {}, + test_url, + {}, + std::nullopt, + "my-domain.net", + std::nullopt, + std::monostate{}, + ExternalAccountAuthorizedUserInfo{"test-client-id", "test-client-secret", + "test-refresh-token"}}; + + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call(make_expected_options())).WillOnce([&]() { + auto mock = std::make_unique(); + auto expected_request = AllOf( + make_expected_token_exchange_request(test_url), + Property( + &RestRequest::headers, + Contains(Pair( + "authorization", + Contains("Basic " + "dGVzdC1jbGllbnQtaWQ6dGVzdC1jbGllbnQtc2VjcmV0"))))); + auto expected_payload = MatcherCast( + UnorderedElementsAre(Pair("grant_type", "refresh_token"), + Pair("refresh_token", "test-refresh-token"))); + EXPECT_CALL(*mock, Post(_, expected_request, expected_payload)) + .WillOnce( + Return(ByMove(MakeMockResponseSuccess(json_response.dump())))); + return mock; + }); + + auto credentials = + ExternalAccountCredentials(info, client_factory.AsStdFunction(), + Options{}.set("test-option")); + auto const now = std::chrono::system_clock::now(); + auto access_token = credentials.GetToken(now); + ASSERT_STATUS_OK(access_token); + EXPECT_EQ(access_token->expiration, now + expected_expires_in); + EXPECT_EQ(access_token->token, expected_access_token); + auto ud = credentials.universe_domain(Options{}); + ASSERT_STATUS_OK(ud); + EXPECT_EQ(*ud, "my-domain.net"); +} + +TEST(ExternalAccountAuthorizedUser, WorkingWithRefreshTokenRotation) { + auto const test_url = std::string{"https://sts.example.com/v1/oauthtoken"}; + auto const expected_expires_in = std::chrono::seconds(3456); + auto const json_response_1 = nlohmann::json{ + {"access_token", "test-access-token-1"}, + {"expires_in", expected_expires_in.count()}, + {"refresh_token", "rotated-refresh-token"}, + {"token_type", "Bearer"}, + }; + auto const json_response_2 = nlohmann::json{ + {"access_token", "test-access-token-2"}, + {"expires_in", expected_expires_in.count()}, + {"token_type", "Bearer"}, + }; + auto const info = ExternalAccountInfo{ + {}, + {}, + test_url, + {}, + std::nullopt, + "googleapis.com", + std::nullopt, + std::monostate{}, + ExternalAccountAuthorizedUserInfo{"test-client-id", "test-client-secret", + "initial-refresh-token"}}; + + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call) + .WillOnce([&]() { + auto mock = std::make_unique(); + auto expected_payload = + MatcherCast(UnorderedElementsAre( + Pair("grant_type", "refresh_token"), + Pair("refresh_token", "initial-refresh-token"))); + EXPECT_CALL(*mock, Post(_, _, expected_payload)) + .WillOnce(Return( + ByMove(MakeMockResponseSuccess(json_response_1.dump())))); + return mock; + }) + .WillOnce([&]() { + auto mock = std::make_unique(); + auto expected_payload = + MatcherCast(UnorderedElementsAre( + Pair("grant_type", "refresh_token"), + Pair("refresh_token", "rotated-refresh-token"))); + EXPECT_CALL(*mock, Post(_, _, expected_payload)) + .WillOnce(Return( + ByMove(MakeMockResponseSuccess(json_response_2.dump())))); + return mock; + }); + + auto credentials = + ExternalAccountCredentials(info, client_factory.AsStdFunction()); + auto const now = std::chrono::system_clock::now(); + auto token1 = credentials.GetToken(now); + ASSERT_STATUS_OK(token1); + EXPECT_EQ(token1->token, "test-access-token-1"); + + auto token2 = credentials.GetToken(now); + ASSERT_STATUS_OK(token2); + EXPECT_EQ(token2->token, "test-access-token-2"); +} + +TEST(ExternalAccountAuthorizedUser, HandleErrors) { + auto const test_url = std::string{"https://sts.example.com/v1/oauthtoken"}; + auto const info = ExternalAccountInfo{ + {}, + {}, + test_url, + {}, + std::nullopt, + "googleapis.com", + std::nullopt, + std::monostate{}, + ExternalAccountAuthorizedUserInfo{"test-client-id", "test-client-secret", + "test-refresh-token"}}; + auto const now = std::chrono::system_clock::now(); + + { + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call).WillOnce([&]() { + auto mock = std::make_unique(); + EXPECT_CALL(*mock, Post(_, _, An())) + .WillOnce(Return(ByMove(MakeMockResponseError()))); + return mock; + }); + auto credentials = + ExternalAccountCredentials(info, client_factory.AsStdFunction()); + EXPECT_THAT(credentials.GetToken(now), StatusIs(StatusCode::kNotFound)); + } + + { + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call).WillOnce([&]() { + auto mock = std::make_unique(); + EXPECT_CALL(*mock, Post(_, _, An())) + .WillOnce([]() -> StatusOr> { + auto response = std::make_unique(); + EXPECT_CALL(*response, StatusCode) + .WillRepeatedly(Return(HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*response), ExtractPayload).WillOnce([] { + auto payload = std::make_unique(); + EXPECT_CALL(*payload, Read) + .WillOnce( + Return(Status(StatusCode::kUnavailable, "read error"))); + return payload; + }); + return std::unique_ptr(std::move(response)); + }); + return mock; + }); + auto credentials = + ExternalAccountCredentials(info, client_factory.AsStdFunction()); + EXPECT_THAT(credentials.GetToken(now), + StatusIs(StatusCode::kUnavailable, HasSubstr("read error"))); + } + + for ( + auto const& bad_payload : std::vector{ + "not-json", + R"({"expires_in": 3600})", + R"({"access_token": "tok"})", + R"({"access_token": "tok", "expires_in": "invalid"})", + R"({"access_token": "tok", "expires_in": 3600, "refresh_token": 123})", + }) { + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call).WillOnce([&]() { + auto mock = std::make_unique(); + EXPECT_CALL(*mock, Post(_, _, An())) + .WillOnce(Return(ByMove(MakeMockResponseSuccess(bad_payload)))); + return mock; + }); + auto credentials = + ExternalAccountCredentials(info, client_factory.AsStdFunction()); + EXPECT_THAT(credentials.GetToken(now), + StatusIs(StatusCode::kInvalidArgument)); + } +} + } // namespace GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal diff --git a/google/cloud/internal/oauth2_google_credentials.cc b/google/cloud/internal/oauth2_google_credentials.cc index 2f51da36f4539..4cf41055c5e7d 100644 --- a/google/cloud/internal/oauth2_google_credentials.cc +++ b/google/cloud/internal/oauth2_google_credentials.cc @@ -52,7 +52,8 @@ StatusOr> LoadCredsFromString( std::make_unique(*info, options, std::move(client_factory))); } - if (cred_type == "external_account") { + if (cred_type == "external_account" || + cred_type == "external_account_authorized_user") { auto info = ParseExternalAccountConfiguration(contents, internal::ErrorContext{}); if (!info) return std::move(info).status(); diff --git a/google/cloud/internal/oauth2_google_credentials_test.cc b/google/cloud/internal/oauth2_google_credentials_test.cc index 0ec96c8f5f6b8..c1f4d516b465a 100644 --- a/google/cloud/internal/oauth2_google_credentials_test.cc +++ b/google/cloud/internal/oauth2_google_credentials_test.cc @@ -92,6 +92,15 @@ auto constexpr kExternalAccountContents = R"""({ "credential_source": {"url": "https://subject.example.com/"} })"""; +auto constexpr kExternalAccountAuthorizedUserContents = R"""({ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/provider-id", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + "token_url": "https://sts.example.com/v1/oauthtoken" +})"""; + auto constexpr kImpersonatedServiceAccountContents = R"""({ "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa3@developer.gserviceaccount.com:generateAccessToken", "delegates": [ @@ -197,6 +206,39 @@ TEST_F(GoogleCredentialsTest, WhenDynamicCastTo(NotNull())); } +TEST_F(GoogleCredentialsTest, + LoadValidExternalAccountAuthorizedUserCredentialsViaEnvVar) { + auto const filename = TempFileName(); + std::ofstream(filename) << kExternalAccountAuthorizedUserContents; + auto const env = ScopedEnvironment(GoogleAdcEnvVar(), filename.c_str()); + + MockHttpClientFactory client_factory; + EXPECT_CALL(client_factory, Call).Times(0); + auto creds = + GoogleDefaultCredentials(Options{}, client_factory.AsStdFunction()); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(creds); + EXPECT_THAT(creds->get(), + WhenDynamicCastTo(NotNull())); +} + +TEST_F(GoogleCredentialsTest, + LoadValidExternalAccountAuthorizedUserCredentialsViaGcloudFile) { + auto const filename = TempFileName(); + std::ofstream(filename) << kExternalAccountAuthorizedUserContents; + auto const env = + ScopedEnvironment(GoogleGcloudAdcFileEnvVar(), filename.c_str()); + + MockHttpClientFactory client_factory; + EXPECT_CALL(client_factory, Call).Times(0); + auto creds = + GoogleDefaultCredentials(Options{}, client_factory.AsStdFunction()); + (void)std::remove(filename.c_str()); + ASSERT_STATUS_OK(creds); + EXPECT_THAT(creds->get(), + WhenDynamicCastTo(NotNull())); +} + /** * @test Verify `GoogleDefaultCredentials()` loads service account credentials. * @@ -353,6 +395,21 @@ TEST_F(GoogleCredentialsTest, LoadInvalidServiceAccountCredentialsViaADC) { (void)std::remove(filename.c_str()); } +TEST_F(GoogleCredentialsTest, + LoadInvalidExternalAccountAuthorizedUserCredentials) { + auto const filename = TempFileName(); + std::ofstream(filename) + << R"""({"type": "external_account_authorized_user"})"""; + auto const env = ScopedEnvironment(GoogleAdcEnvVar(), filename.c_str()); + + MockHttpClientFactory client_factory; + EXPECT_CALL(client_factory, Call).Times(0); + auto creds = + GoogleDefaultCredentials(Options{}, client_factory.AsStdFunction()); + EXPECT_THAT(creds, StatusIs(StatusCode::kInvalidArgument)); + (void)std::remove(filename.c_str()); +} + TEST_F(GoogleCredentialsTest, LoadInvalidImpersonatedServiceAccountCredentials) { auto const filename = TempFileName(); diff --git a/google/cloud/internal/unified_grpc_credentials.cc b/google/cloud/internal/unified_grpc_credentials.cc index f53d2698a953a..7a692aaa14728 100644 --- a/google/cloud/internal/unified_grpc_credentials.cc +++ b/google/cloud/internal/unified_grpc_credentials.cc @@ -143,13 +143,20 @@ std::shared_ptr CreateAuthenticationStrategy( } } void visit(ExternalAccountConfig const& cfg) override { + auto external_account_creds = GrpcExternalAccountCredentials(cfg); + if (!external_account_creds) { + result = std::make_unique( + ErrorCredentialsConfig{InvalidArgumentError( + "Error creating grpc::ExternalAccountCredentials", + 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), - GrpcExternalAccountCredentials(cfg))); + grpc::CompositeChannelCredentials(grpc::SslCredentials(ssl_options), + std::move(external_account_creds))); } void visit(ApiKeyConfig const& cfg) override { result = std::make_unique(cfg.api_key()); diff --git a/google/cloud/internal/unified_grpc_credentials_test.cc b/google/cloud/internal/unified_grpc_credentials_test.cc index 697b9a501f8cc..d70e60b209e6d 100644 --- a/google/cloud/internal/unified_grpc_credentials_test.cc +++ b/google/cloud/internal/unified_grpc_credentials_test.cc @@ -164,6 +164,37 @@ TEST(UnifiedGrpcCredentialsTest, WithAuthorizedUserCredentials) { EXPECT_THAT(configured_context, StatusIs(StatusCode::kUnimplemented)); } +TEST(UnifiedGrpcCredentialsTest, WithExternalAccountCredentialsInvalid) { + CompletionQueue cq; + auto creds = ExternalAccountConfig("{}", Options{}); + auto auth = CreateAuthenticationStrategy(creds, cq); + ASSERT_THAT(auth, NotNull()); + EXPECT_TRUE(auth->RequiresConfigureContext()); + + grpc::ClientContext context; + auto configured_context = auth->ConfigureContext(context); + EXPECT_THAT(configured_context, StatusIs(StatusCode::kInvalidArgument)); +} + +TEST(UnifiedGrpcCredentialsTest, WithExternalAccountAuthorizedUserCredentials) { + CompletionQueue cq; + std::string const json = R"({ + "type": "external_account_authorized_user", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + "token_url": "https://sts.googleapis.com/v1/oauthtoken" + })"; + auto creds = ExternalAccountConfig(json, Options{}); + auto auth = CreateAuthenticationStrategy(creds, cq); + ASSERT_THAT(auth, NotNull()); + EXPECT_TRUE(auth->RequiresConfigureContext()); + + grpc::ClientContext context; + auto configured_context = auth->ConfigureContext(context); + EXPECT_THAT(configured_context, StatusIs(StatusCode::kInvalidArgument)); +} + TEST(UnifiedGrpcCredentialsTest, LoadCAInfoNotSet) { auto contents = LoadCAInfo(Options{}); EXPECT_FALSE(contents.has_value()); diff --git a/google/cloud/internal/unified_rest_credentials_test.cc b/google/cloud/internal/unified_rest_credentials_test.cc index 7b9f057e1b6ee..fe6957be6ded4 100644 --- a/google/cloud/internal/unified_rest_credentials_test.cc +++ b/google/cloud/internal/unified_rest_credentials_test.cc @@ -395,6 +395,52 @@ TEST(UnifiedRestCredentialsTest, AdcIsExternalAccount) { StatusIs(StatusCode::kPermissionDenied, "uh-oh - STS exchange")); } +TEST(UnifiedRestCredentialsTest, AdcIsExternalAccountAuthorizedUser) { + auto const sts_url = std::string{"https://sts.example.com/v1/oauthtoken"}; + auto sts_client = [sts_url] { + using FormDataType = std::vector>; + auto expected_sts_request = AllOf( + Property(&RestRequest::path, sts_url), + Property( + &RestRequest::headers, + Contains(Pair( + "authorization", + Contains("Basic " + "dGVzdC1jbGllbnQtaWQ6dGVzdC1jbGllbnQtc2VjcmV0"))))); + auto expected_form_data = MatcherCast(IsSupersetOf({ + Pair("grant_type", "refresh_token"), + Pair("refresh_token", "test-refresh-token"), + })); + auto mock = std::make_unique(); + EXPECT_CALL(*mock, Post(_, expected_sts_request, expected_form_data)) + .WillOnce(Return( + Status{StatusCode::kPermissionDenied, "uh-oh - STS refresh"})); + return mock; + }(); + + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call) + .WillOnce(Return(ByMove(std::move(sts_client)))); + + auto const json_external_account = nlohmann::json{ + {"type", "external_account_authorized_user"}, + {"client_id", "test-client-id"}, + {"client_secret", "test-client-secret"}, + {"refresh_token", "test-refresh-token"}, + {"token_url", sts_url}, + }; + auto const filename = TempKeyFileName(); + auto const env = SetUpAdcFile(filename, json_external_account.dump()); + auto const config = internal::GoogleDefaultCredentialsConfig(Options{}); + auto credentials = MapCredentials(config, client_factory.AsStdFunction()); + (void)std::remove(filename.c_str()); + + auto const now = std::chrono::system_clock::now(); + auto access_token = credentials->GetToken(now); + EXPECT_THAT(access_token, + StatusIs(StatusCode::kPermissionDenied, "uh-oh - STS refresh")); +} + TEST(UnifiedRestCredentialsTest, AccessToken) { auto const now = std::chrono::system_clock::now(); auto const expiration = now + std::chrono::seconds(1800); @@ -548,6 +594,53 @@ TEST(UnifiedRestCredentialsTest, ExternalAccount) { StatusIs(StatusCode::kPermissionDenied, "uh-oh - STS exchange")); } +TEST(UnifiedRestCredentialsTest, ExternalAccountAuthorizedUser) { + auto const sts_url = std::string{"https://sts.example.com/v1/oauthtoken"}; + auto sts_client = [sts_url] { + using FormDataType = std::vector>; + auto expected_sts_request = AllOf( + Property(&RestRequest::path, sts_url), + Property( + &RestRequest::headers, + Contains(Pair( + "authorization", + Contains("Basic " + "dGVzdC1jbGllbnQtaWQ6dGVzdC1jbGllbnQtc2VjcmV0"))))); + auto expected_form_data = MatcherCast(IsSupersetOf({ + Pair("grant_type", "refresh_token"), + Pair("refresh_token", "test-refresh-token"), + })); + auto mock = std::make_unique(); + EXPECT_CALL(*mock, Post(_, expected_sts_request, expected_form_data)) + .WillOnce(Return( + Status{StatusCode::kPermissionDenied, "uh-oh - STS refresh"})); + return mock; + }(); + + auto const json_external_account = nlohmann::json{ + {"type", "external_account_authorized_user"}, + {"client_id", "test-client-id"}, + {"client_secret", "test-client-secret"}, + {"refresh_token", "test-refresh-token"}, + {"token_url", sts_url}, + {"universe_domain", "s3nsapis.fr"}, + }; + + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call) + .WillOnce(Return(ByMove(std::move(sts_client)))); + + auto const config = + internal::ExternalAccountConfig(json_external_account.dump(), Options{}); + auto credentials = MapCredentials(config, client_factory.AsStdFunction()); + EXPECT_THAT(credentials->universe_domain(Options{}), + IsOkAndHolds("s3nsapis.fr")); + auto const now = std::chrono::system_clock::now(); + auto access_token = credentials->GetToken(now); + EXPECT_THAT(access_token, + StatusIs(StatusCode::kPermissionDenied, "uh-oh - STS refresh")); +} + TEST(UnifiedRestCredentialsTest, AuthorizedUserParseError) { MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).Times(0); @@ -558,6 +651,17 @@ TEST(UnifiedRestCredentialsTest, AuthorizedUserParseError) { EXPECT_THAT(access_token, Not(IsOk())); } +TEST(UnifiedRestCredentialsTest, ExternalAccountAuthorizedUserParseError) { + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call).Times(0); + + auto const config = internal::ExternalAccountConfig( + R"({"type": "external_account_authorized_user"})", Options{}); + auto credentials = MapCredentials(config, client_factory.AsStdFunction()); + auto access_token = credentials->GetToken(std::chrono::system_clock::now()); + EXPECT_THAT(access_token, Not(IsOk())); +} + TEST(UnifiedRestCredentialsTest, ApiKey) { auto creds = MakeApiKeyCredentials("api-key"); ASSERT_THAT(creds, NotNull());