From a552c933ebfffd1c7e73794b003c84e590bfb82b Mon Sep 17 00:00:00 2001 From: Yao Cui Date: Mon, 28 Oct 2024 13:21:43 -0400 Subject: [PATCH] feat(oauth2): add support for external account workforce identity (#14800) * feat(oauth2): add support for external account workforce identity * move * avoid cmake dep * format * address the comments --- .../oauth2_external_account_credentials.cc | 29 ++++- .../oauth2_external_account_credentials.h | 1 + ...auth2_external_account_credentials_test.cc | 113 ++++++++++++++++-- 3 files changed, 125 insertions(+), 18 deletions(-) diff --git a/google/cloud/internal/oauth2_external_account_credentials.cc b/google/cloud/internal/oauth2_external_account_credentials.cc index 29966e4eb6901..c50f7f116bfd9 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.cc +++ b/google/cloud/internal/oauth2_external_account_credentials.cc @@ -96,12 +96,21 @@ StatusOr ParseExternalAccountConfiguration( MakeExternalAccountTokenSource(*credential_source, *audience, ec); if (!source) return std::move(source).status(); - auto info = - ExternalAccountInfo{*std::move(audience), *std::move(subject_token_type), - *std::move(token_url), *std::move(source), - absl::nullopt, *std::move(universe_domain)}; + absl::optional workforce_pool_user_project; + auto it = json.find("workforce_pool_user_project"); + if (it != json.end()) { + workforce_pool_user_project = it->get(); + } + + auto info = ExternalAccountInfo{*std::move(audience), + *std::move(subject_token_type), + *std::move(token_url), + *std::move(source), + absl::nullopt, + *std::move(universe_domain), + std::move(workforce_pool_user_project)}; - auto it = json.find("service_account_impersonation_url"); + it = json.find("service_account_impersonation_url"); if (it == json.end()) return info; auto constexpr kDefaultImpersonationTokenLifetime = @@ -148,6 +157,16 @@ StatusOr ExternalAccountCredentials::GetToken( {"subject_token_type", info_.subject_token_type}, {"subject_token", subject_token->token}, }; + + // Workforce Identity is handled at the org level and requires the userProject + // header. Workload Identity is handled at the project level and doesn't + // require the header. + if (info_.workforce_pool_user_project) { + form_data.emplace_back( + "options", absl::StrCat(R"({"userProject": ")", + *info_.workforce_pool_user_project, R"("})")); + } + auto request = rest_internal::RestRequest(info_.token_url) .AddHeader("content-type", "application/x-www-form-urlencoded"); diff --git a/google/cloud/internal/oauth2_external_account_credentials.h b/google/cloud/internal/oauth2_external_account_credentials.h index faad2af5176a8..efd9c425f800c 100644 --- a/google/cloud/internal/oauth2_external_account_credentials.h +++ b/google/cloud/internal/oauth2_external_account_credentials.h @@ -68,6 +68,7 @@ struct ExternalAccountInfo { ExternalAccountTokenSource token_source; absl::optional impersonation_config; std::string universe_domain; + absl::optional workforce_pool_user_project; }; /// Parse a JSON string with an external account configuration. diff --git a/google/cloud/internal/oauth2_external_account_credentials_test.cc b/google/cloud/internal/oauth2_external_account_credentials_test.cc index 749d72565d84c..d28a262df96b4 100644 --- a/google/cloud/internal/oauth2_external_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_external_account_credentials_test.cc @@ -48,6 +48,7 @@ using ::testing::Contains; using ::testing::ElementsAre; using ::testing::HasSubstr; using ::testing::MatcherCast; +using ::testing::Optional; using ::testing::Pair; using ::testing::Property; using ::testing::ResultOf; @@ -304,6 +305,27 @@ TEST(ExternalAccount, ParseWithImpersonationDefaultLifetimeSuccess) { std::chrono::seconds(3600)); } +TEST(ExternalAccount, ParseUserProjectSuccess) { + auto const configuration = nlohmann::json{ + {"type", "external_account"}, + {"audience", "test-audience"}, + {"subject_token_type", "test-subject-token-type"}, + {"token_url", "test-token-url"}, + {"credential_source", nlohmann::json{{"file", "/dev/null-test-only"}}}, + {"workforce_pool_user_project", "project-id-or-name"}, + }; + auto ec = internal::ErrorContext( + {{"program", "test"}, {"full-configuration", configuration.dump()}}); + auto const actual = + ParseExternalAccountConfiguration(configuration.dump(), ec); + ASSERT_STATUS_OK(actual); + EXPECT_EQ(actual->audience, "test-audience"); + EXPECT_EQ(actual->subject_token_type, "test-subject-token-type"); + EXPECT_EQ(actual->token_url, "test-token-url"); + EXPECT_THAT(actual->workforce_pool_user_project, + Optional(std::string("project-id-or-name"))); +} + TEST(ExternalAccount, ParseNotJson) { auto const configuration = std::string{"not-json"}; auto ec = internal::ErrorContext( @@ -657,7 +679,8 @@ TEST(ExternalAccount, Working) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call(make_expected_options())).WillOnce([&]() { @@ -689,6 +712,58 @@ TEST(ExternalAccount, Working) { EXPECT_EQ(access_token->token, expected_access_token); } +TEST(ExternalAccount, WorkingWorkforceIdentity) { + auto const test_url = std::string{"https://sts.example.com/"}; + 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()}, + {"issued_token_type", "urn:ietf:params:oauth:token-type:access_token"}, + {"token_type", "Bearer"}, + }; + auto mock_source = [](HttpClientFactory const&, Options const&) { + return make_status_or(internal::SubjectToken{"test-subject-token"}); + }; + auto const info = ExternalAccountInfo{"test-audience", + "test-subject-token-type", + test_url, + mock_source, + absl::nullopt, + {}, + "project-id-or-name"}; + + MockClientFactory client_factory; + EXPECT_CALL(client_factory, Call(make_expected_options())).WillOnce([&]() { + auto mock = std::make_unique(); + auto expected_request = make_expected_token_exchange_request(test_url); + auto expected_payload = + MatcherCast(UnorderedElementsAre( + Pair("grant_type", + "urn:ietf:params:oauth:grant-type:token-exchange"), + Pair("requested_token_type", + "urn:ietf:params:oauth:token-type:access_token"), + Pair("scope", "https://www.googleapis.com/auth/cloud-platform"), + Pair("audience", "test-audience"), + Pair("subject_token_type", "test-subject-token-type"), + Pair("subject_token", "test-subject-token"), + Pair("options", R"({"userProject": "project-id-or-name"})"))); + 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); +} + TEST(ExternalAccount, WorkingWithImpersonation) { auto const sts_test_url = std::string{"https://sts.example.com/"}; auto const sts_access_token = std::string{"test-sts-access-token"}; @@ -727,7 +802,8 @@ TEST(ExternalAccount, WorkingWithImpersonation) { mock_source, ExternalAccountImpersonationConfig{ impersonate_test_url, impersonate_test_lifetime}, - {}}; + {}, + absl::nullopt}; auto sts_client = [&] { auto expected_sts_request = Property(&RestRequest::path, sts_test_url); @@ -798,7 +874,8 @@ TEST(ExternalAccount, HandleHttpError) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -836,7 +913,8 @@ TEST(ExternalAccount, HandleHttpPartialError) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -875,7 +953,8 @@ TEST(ExternalAccount, HandleNotJson) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -914,7 +993,8 @@ TEST(ExternalAccount, HandleNotJsonObject) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -959,7 +1039,8 @@ TEST(ExternalAccount, MissingToken) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -993,7 +1074,8 @@ TEST(ExternalAccount, MissingIssuedTokenType) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1027,7 +1109,8 @@ TEST(ExternalAccount, MissingTokenType) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1061,7 +1144,8 @@ TEST(ExternalAccount, InvalidIssuedTokenType) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1097,7 +1181,8 @@ TEST(ExternalAccount, InvalidTokenType) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1134,7 +1219,8 @@ TEST(ExternalAccount, MissingExpiresIn) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique(); @@ -1169,7 +1255,8 @@ TEST(ExternalAccount, InvalidExpiresIn) { auto const info = ExternalAccountInfo{"test-audience", "test-subject-token-type", test_url, mock_source, - absl::nullopt, {}}; + absl::nullopt, {}, + absl::nullopt}; MockClientFactory client_factory; EXPECT_CALL(client_factory, Call).WillOnce([&]() { auto mock = std::make_unique();