diff --git a/extensions/authn/BUILD b/extensions/authn/BUILD new file mode 100644 index 00000000000..ba74ba879e8 --- /dev/null +++ b/extensions/authn/BUILD @@ -0,0 +1,149 @@ +# Copyright 2018 Istio Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# + +package(default_visibility = ["//visibility:public"]) + +load( + "@envoy//bazel:envoy_build_system.bzl", + "envoy_cc_library", + "envoy_cc_test", + "envoy_cc_test_library", +) + +envoy_cc_library( + name = "authenticator_essential_lib", + srcs = [ + "authn_utils.cc", + "connection_context.cc", + "filter_context.cc", + ], + hdrs = [ + "authn_utils.h", + "connection_context.h", + "filter_context.h", + ], + repository = "@envoy", + visibility = ["//visibility:public"], + deps = [ + "//extensions/common:json_util", + "//external:security_authentication_config_cc_proto", + "//src/envoy/utils:filter_names_lib", + "//src/envoy/utils:utils_lib", + "//src/istio/authn:context_proto_cc_proto", + "@envoy//source/common/http:headers_lib", + "@envoy//source/exe:envoy_common_lib", + ], +) + +envoy_cc_library( + name = "request_authenticator_lib", + srcs = [ + "request_authenticator.cc", + ], + hdrs = [ + "request_authenticator.h", + ], + repository = "@envoy", + visibility = ["//visibility:public"], + deps = [ + ":authenticator_essential_lib", + ], +) + +envoy_cc_library( + name = "peer_authenticator_lib", + srcs = [ + "peer_authenticator.cc", + ], + hdrs = [ + "peer_authenticator.h", + ], + repository = "@envoy", + visibility = ["//visibility:public"], + deps = [ + ":authenticator_essential_lib", + ], +) + +envoy_cc_test_library( + name = "test_utils", + hdrs = ["test_utils.h"], + repository = "@envoy", + deps = [ + "//src/istio/authn:context_proto_cc_proto", + ], +) + +envoy_cc_test( + name = "request_authenticator_test", + srcs = ["request_authenticator_test.cc"], + repository = "@envoy", + deps = [ + ":request_authenticator_lib", + ":test_utils", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "connection_context_test", + srcs = ["connection_context_test.cc"], + repository = "@envoy", + deps = [ + ":request_authenticator_lib", + ":test_utils", + "@envoy//test/mocks/network:network_mocks", + "@envoy//test/mocks/ssl:ssl_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "peer_authenticator_test", + srcs = ["peer_authenticator_test.cc"], + repository = "@envoy", + deps = [ + ":peer_authenticator_lib", + ":test_utils", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "filter_context_test", + srcs = ["filter_context_test.cc"], + repository = "@envoy", + deps = [ + ":authenticator_essential_lib", + ":test_utils", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "authn_utils_test", + srcs = ["authn_utils_test.cc"], + repository = "@envoy", + deps = [ + ":authenticator_essential_lib", + ":test_utils", + "@envoy//test/test_common:utility_lib", + ], +) diff --git a/extensions/authn/authn_utils.cc b/extensions/authn/authn_utils.cc new file mode 100644 index 00000000000..01c4be577c8 --- /dev/null +++ b/extensions/authn/authn_utils.cc @@ -0,0 +1,125 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "authn_utils.h" + +#include + +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" +#include "extensions/common/json_util.h" +#include "google/protobuf/struct.pb.h" + +namespace Extensions { +namespace AuthN { +namespace { +// The JWT audience key name +static const std::string kJwtAudienceKey = "aud"; +// The JWT issuer key name +static const std::string kJwtIssuerKey = "iss"; +// The key name for the original claims in an exchanged token +static const std::string kExchangedTokenOriginalPayload = "original_claims"; + +}; // namespace + +bool AuthnUtils::ProcessJwtPayload(const std::string& payload_str, + istio::authn::JwtPayload* payload) { + auto result = Wasm::Common::JsonParse(payload_str); + if (!result.has_value()) { + return false; + } + auto json_obj = result.value(); + + *payload->mutable_raw_claims() = payload_str; + + auto claims = payload->mutable_claims()->mutable_fields(); + // Extract claims as string lists + Wasm::Common::JsonObjectIterate( + json_obj, [&json_obj, &claims](const std::string& key) -> bool { + // In current implementation, only string/string list objects are + // extracted + std::vector list; + auto field_value = + Wasm::Common::JsonGetField>(json_obj, + key); + if (field_value.detail() != Wasm::Common::JsonParserResultDetail::OK) { + auto str_field_value = + Wasm::Common::JsonGetField(json_obj, key); + if (str_field_value.detail() != + Wasm::Common::JsonParserResultDetail::OK) { + return true; + } + list = absl::StrSplit(str_field_value.value().data(), ' ', + absl::SkipEmpty()); + } else { + list = field_value.value(); + } + for (auto& s : list) { + (*claims)[key].mutable_list_value()->add_values()->set_string_value( + std::string(s)); + } + return true; + }); + + // Copy audience to the audience in context.proto + if (claims->find(kJwtAudienceKey) != claims->end()) { + for (const auto& v : (*claims)[kJwtAudienceKey].list_value().values()) { + payload->add_audiences(v.string_value()); + } + } + + // Build user + if (claims->find("iss") != claims->end() && + claims->find("sub") != claims->end()) { + payload->set_user( + (*claims)["iss"].list_value().values().Get(0).string_value() + "/" + + (*claims)["sub"].list_value().values().Get(0).string_value()); + } + // Build authorized presenter (azp) + if (claims->find("azp") != claims->end()) { + payload->set_presenter( + (*claims)["azp"].list_value().values().Get(0).string_value()); + } + + return true; +} + +bool AuthnUtils::ExtractOriginalPayload(const std::string& token, + std::string* original_payload) { + auto result = Wasm::Common::JsonParse(token); + if (!result.has_value()) { + return false; + } + auto json_obj = result.value(); + + if (!json_obj.contains(kExchangedTokenOriginalPayload)) { + return false; + } + + auto original_payload_obj = + Wasm::Common::JsonGetField( + json_obj, kExchangedTokenOriginalPayload); + if (original_payload_obj.detail() != + Wasm::Common::JsonParserResultDetail::OK) { + return false; + } + *original_payload = original_payload_obj.value().dump(); + + return true; +} + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/authn_utils.h b/extensions/authn/authn_utils.h new file mode 100644 index 00000000000..a29a8cebeac --- /dev/null +++ b/extensions/authn/authn_utils.h @@ -0,0 +1,41 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "absl/strings/string_view.h" +#include "src/istio/authn/context.pb.h" + +namespace Extensions { +namespace AuthN { + +// AuthnUtils class provides utility functions used for authentication. +class AuthnUtils { + public: + // Parse JWT payload string (which typically is the output from jwt filter) + // and populate JwtPayload object. Return true if input string can be parsed + // successfully. Otherwise, return false. + static bool ProcessJwtPayload(const std::string& jwt_payload_str, + istio::authn::JwtPayload* payload); + + // Parses the original_payload in an exchanged JWT. + // Returns true if original_payload can be + // parsed successfully. Otherwise, returns false. + static bool ExtractOriginalPayload(const std::string& token, + std::string* original_payload); +}; + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/authn_utils_test.cc b/extensions/authn/authn_utils_test.cc new file mode 100644 index 00000000000..ad8d2c7670d --- /dev/null +++ b/extensions/authn/authn_utils_test.cc @@ -0,0 +1,274 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "extensions/authn/authn_utils.h" + +#include "common/common/base64.h" +#include "common/common/utility.h" +#include "extensions/authn/test_utils.h" +#include "test/test_common/utility.h" + +using google::protobuf::util::MessageDifferencer; +using istio::authn::JwtPayload; + +namespace Extensions { +namespace AuthN { +namespace { + +const std::string kSecIstioAuthUserinfoHeaderValue = + R"( + { + "iss": "issuer@foo.com", + "sub": "sub@foo.com", + "aud": "aud1", + "non-string-will-be-ignored": 1512754205, + "some-other-string-claims": "some-claims-kept" + } + )"; +const std::string kSecIstioAuthUserInfoHeaderWithAudValueList = + R"( + { + "iss": "issuer@foo.com", + "sub": "sub@foo.com", + "aud": "aud1 aud2", + "non-string-will-be-ignored": 1512754205, + "some-other-string-claims": "some-claims-kept" + } + )"; +const std::string kSecIstioAuthUserInfoHeaderWithAudValueArray = + R"( + { + "iss": "issuer@foo.com", + "sub": "sub@foo.com", + "aud": ["aud1", "aud2"], + "non-string-will-be-ignored": 1512754205, + "some-other-string-claims": "some-claims-kept" + } + )"; + +TEST(AuthnUtilsTest, GetJwtPayloadFromHeaderTest) { + JwtPayload payload, expected_payload; + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + R"( + user: "issuer@foo.com/sub@foo.com" + audiences: ["aud1"] + claims: { + fields: { + key: "aud" + value: { + list_value: { + values: { + string_value: "aud1" + } + } + } + } + fields: { + key: "iss" + value: { + list_value: { + values: { + string_value: "issuer@foo.com" + } + } + } + } + fields: { + key: "sub" + value: { + list_value: { + values: { + string_value: "sub@foo.com" + } + } + } + } + fields: { + key: "some-other-string-claims" + value: { + list_value: { + values: { + string_value: "some-claims-kept" + } + } + } + } + } + raw_claims: ")" + + Envoy::StringUtil::escape(kSecIstioAuthUserinfoHeaderValue) + R"(")", + &expected_payload)); + // The payload returned from ProcessJwtPayload() should be the same as + // the expected. + bool ret = + AuthnUtils::ProcessJwtPayload(kSecIstioAuthUserinfoHeaderValue, &payload); + EXPECT_TRUE(ret); + EXPECT_TRUE(MessageDifferencer::Equals(expected_payload, payload)); +} + +TEST(AuthnUtilsTest, ProcessJwtPayloadWithAudListTest) { + JwtPayload payload, expected_payload; + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + R"( + user: "issuer@foo.com/sub@foo.com" + audiences: "aud1" + audiences: "aud2" + claims: { + fields: { + key: "iss" + value: { + list_value: { + values: { + string_value: "issuer@foo.com" + } + } + } + } + fields: { + key: "sub" + value: { + list_value: { + values: { + string_value: "sub@foo.com" + } + } + } + } + fields: { + key: "aud" + value: { + list_value: { + values: { + string_value: "aud1" + } + values: { + string_value: "aud2" + } + } + } + } + fields: { + key: "some-other-string-claims" + value: { + list_value: { + values: { + string_value: "some-claims-kept" + } + } + } + } + } + raw_claims: ")" + + Envoy::StringUtil::escape( + kSecIstioAuthUserInfoHeaderWithAudValueList) + + R"(")", + &expected_payload)); + // The payload returned from ProcessJwtPayload() should be the same as + // the expected. When there is no aud, the aud is not saved in the payload + // and claims. + bool ret = AuthnUtils::ProcessJwtPayload( + kSecIstioAuthUserInfoHeaderWithAudValueList, &payload); + EXPECT_TRUE(ret); + EXPECT_TRUE(MessageDifferencer::Equals(expected_payload, payload)); +} + +TEST(AuthnUtilsTest, ProcessJwtPayloadWithAudArrayTest) { + JwtPayload payload, expected_payload; + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + R"( + user: "issuer@foo.com/sub@foo.com" + audiences: "aud1" + audiences: "aud2" + claims: { + fields: { + key: "aud" + value: { + list_value: { + values: { + string_value: "aud1" + } + values: { + string_value: "aud2" + } + } + } + } + fields: { + key: "iss" + value: { + list_value: { + values: { + string_value: "issuer@foo.com" + } + } + } + } + fields: { + key: "sub" + value: { + list_value: { + values: { + string_value: "sub@foo.com" + } + } + } + } + fields: { + key: "some-other-string-claims" + value: { + list_value: { + values: { + string_value: "some-claims-kept" + } + } + } + } + } + raw_claims: ")" + + Envoy::StringUtil::escape( + kSecIstioAuthUserInfoHeaderWithAudValueArray) + + R"(")", + &expected_payload)); + // The payload returned from ProcessJwtPayload() should be the same as + // the expected. When the aud is a string array, the aud is not saved in the + // claims. + bool ret = AuthnUtils::ProcessJwtPayload( + kSecIstioAuthUserInfoHeaderWithAudValueArray, &payload); + + EXPECT_TRUE(ret); + EXPECT_TRUE(MessageDifferencer::Equals(expected_payload, payload)); +} + +TEST(AuthnUtilsTest, ExtractOriginalPayloadTest) { + std::string payload_str; + std::string token = R"( + { + "iss": "token-service", + "sub": "subject", + "aud": ["aud1", "aud2"], + "original_claims": { + "iss": "https://accounts.example.com", + "sub": "example-subject", + "email": "user@example.com" + } + } + )"; + EXPECT_TRUE(AuthnUtils::ExtractOriginalPayload(token, &payload_str)); + + std::string token2 = "{}"; + EXPECT_FALSE(AuthnUtils::ExtractOriginalPayload(token2, &payload_str)); +} + +} // namespace +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/connection_context.cc b/extensions/authn/connection_context.cc new file mode 100644 index 00000000000..6840636b812 --- /dev/null +++ b/extensions/authn/connection_context.cc @@ -0,0 +1,111 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/connection_context.h" + +#include "absl/strings/string_view.h" + +namespace Extensions { +namespace AuthN { + +namespace { +static constexpr absl::string_view kSpiffePrefix = "spiffe://"; + +bool hasSpiffePrefix(const std::string& url) { + return url.find(kSpiffePrefix.data()) != std::string::npos; +} +} // namespace + +ConnectionContextImpl::ConnectionContextImpl(const Connection* connection) + : connection_(connection) {} + +absl::optional ConnectionContextImpl::trustDomain(bool peer) { + const auto cert_san = certSan(peer); + if (!cert_san.has_value() || !hasSpiffePrefix(cert_san.value())) { + return absl::nullopt; + } + + // Skip the prefix "spiffe://" before getting trust domain. + auto slash = cert_san->find('/', kSpiffePrefix.size()); + if (slash == std::string::npos) { + return absl::nullopt; + } + + auto domain_len = slash - kSpiffePrefix.size(); + return cert_san->substr(kSpiffePrefix.size(), domain_len); +} + +absl::optional ConnectionContextImpl::principalDomain(bool peer) { + const auto cert_san = certSan(peer); + if (cert_san.has_value()) { + if (hasSpiffePrefix(cert_san.value())) { + // Strip out the prefix "spiffe://" in the identity. + return cert_san->substr(kSpiffePrefix.size()); + } else { + return cert_san; + } + } + return absl::nullopt; +} + +bool ConnectionContextImpl::isMutualTls() const { + return connection_ != nullptr && connection_->ssl() != nullptr && + connection_->ssl()->peerCertificatePresented(); +} + +absl::optional ConnectionContextImpl::port() const { + if (!connection_) { + return absl::nullopt; + } + const auto local_address_instance = connection_->localAddress(); + assert(local_address_instance != nullptr); + const auto ip = local_address_instance->ip(); + if (!ip) { + return absl::nullopt; + } + return ip->port(); +} + +absl::optional ConnectionContextImpl::certSan(bool peer) { + if (!connection_) { + return absl::nullopt; + } + const auto ssl = connection_->ssl(); + if (!ssl) { + return absl::nullopt; + } + const auto& sans = + (peer ? ssl->uriSanPeerCertificate() : ssl->uriSanLocalCertificate()); + if (sans.empty()) { + // empty result is not allowed. + return absl::nullopt; + } + std::string picked_san; + // return the first san with the 'spiffe://' prefix + for (const auto& san : sans) { + if (hasSpiffePrefix(san)) { + picked_san = san; + break; + } + } + + if (picked_san.empty()) { + picked_san = sans[0]; + } + + return picked_san; +} +} // namespace AuthN +} // namespace Extensions \ No newline at end of file diff --git a/extensions/authn/connection_context.h b/extensions/authn/connection_context.h new file mode 100644 index 00000000000..1eb7e8a3aff --- /dev/null +++ b/extensions/authn/connection_context.h @@ -0,0 +1,70 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "absl/types/optional.h" +#include "envoy/network/connection.h" + +namespace Extensions { +namespace AuthN { + +using Envoy::Network::Connection; + +class ConnectionContext { + public: + virtual ~ConnectionContext() = default; + + // Peer or local trust domain. + // It will return only `spiffe` prefixed domain. + virtual absl::optional trustDomain(bool peer) PURE; + + // Peer or local principal domain. + // It will return arbitary domain which will extracted from SAN. + virtual absl::optional principalDomain(bool peer) PURE; + + // Check whether established connection enabled mTLS or not. + virtual bool isMutualTls() const PURE; + + // Connection port. + virtual absl::optional port() const PURE; +}; + +class ConnectionContextImpl : public ConnectionContext { + public: + ConnectionContextImpl(const Connection* connection); + + // ConnectionContext + absl::optional trustDomain(bool peer) override; + + absl::optional principalDomain(bool peer) override; + + bool isMutualTls() const override; + + absl::optional port() const override; + + private: + // Get SAN from peer or local TLS certificate. It will return first `spiffe` + // prefixed SAN. If there is no `spiffe` prefixed SAN, it will return first + // SAN. + absl::optional certSan(bool peer); + + const Connection* connection_; +}; + +using ConnectionContextPtr = std::shared_ptr; + +} // namespace AuthN +} // namespace Extensions \ No newline at end of file diff --git a/extensions/authn/connection_context_test.cc b/extensions/authn/connection_context_test.cc new file mode 100644 index 00000000000..8240bf49dcb --- /dev/null +++ b/extensions/authn/connection_context_test.cc @@ -0,0 +1,153 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/connection_context.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "test/mocks/network/mocks.h" +#include "test/mocks/ssl/mocks.h" + +using testing::Return; +using testing::ReturnRef; + +namespace Extensions { +namespace AuthN { +namespace { + +class ConnectionContextTest : public testing::Test { + public: + Envoy::Network::MockConnection connection_; + std::shared_ptr ssl_conn_info_{ + std::make_shared()}; + ConnectionContextImpl conn_context_{&connection_}; +}; + +TEST_F(ConnectionContextTest, IsMutualTlsTest) { + EXPECT_CALL(connection_, ssl()) + .Times(2) + .WillRepeatedly(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, peerCertificatePresented()) + .WillOnce(Return(true)); + EXPECT_TRUE(conn_context_.isMutualTls()); +} + +TEST_F(ConnectionContextTest, TrustDomainTestWithoutSpiffePrefix) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce(Return(std::vector{"istio.io", "istio2.io"})); + EXPECT_FALSE(conn_context_.trustDomain(true).has_value()); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce(Return(std::vector{"istio.io", "istio2.io"})); + EXPECT_FALSE(conn_context_.trustDomain(false).has_value()); +} + +TEST_F(ConnectionContextTest, TrustDomainTestWithSpiffePrefix) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe://istio2.io/"})); + EXPECT_EQ(conn_context_.trustDomain(true).value(), "istio2.io"); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe://istio2.io/"})); + EXPECT_EQ(conn_context_.trustDomain(false).value(), "istio2.io"); +} + +TEST_F(ConnectionContextTest, TrustDomainTestWithInvalidSpiffePrefix) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe:/istio2.io"})); + EXPECT_FALSE(conn_context_.trustDomain(true).has_value()); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe:/istio2.io"})); + EXPECT_FALSE(conn_context_.trustDomain(false).has_value()); +} + +TEST_F(ConnectionContextTest, TrustDomainTestWithInvalidSpiffePrefixOnly) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce(Return(std::vector{"spiffe:/istio2.io"})); + EXPECT_FALSE(conn_context_.trustDomain(true).has_value()); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce(Return(std::vector{"spiffe:/istio2.io"})); + EXPECT_FALSE(conn_context_.trustDomain(false).has_value()); +} + +TEST_F(ConnectionContextTest, PrincipalDomainTestWithoutSpiffePrefix) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce(Return(std::vector{"istio.io", "istio2.io"})); + EXPECT_EQ(conn_context_.principalDomain(true).value(), "istio.io"); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce(Return(std::vector{"istio.io", "istio2.io"})); + EXPECT_EQ(conn_context_.principalDomain(false).value(), "istio.io"); +} + +TEST_F(ConnectionContextTest, PrincipalDomainTestWithSpiffePrefix) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe://istio2.io/"})); + EXPECT_EQ(conn_context_.principalDomain(true).value(), "istio2.io/"); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe://istio2.io/"})); + EXPECT_EQ(conn_context_.principalDomain(false).value(), "istio2.io/"); +} + +TEST_F(ConnectionContextTest, PrincipalDomainTestWithInvalidSpiffePrefix) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe:/istio2.io"})); + EXPECT_EQ(conn_context_.principalDomain(true).value(), "istio.io"); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce( + Return(std::vector{"istio.io", "spiffe:/istio2.io"})); + EXPECT_EQ(conn_context_.principalDomain(false).value(), "istio.io"); +} + +TEST_F(ConnectionContextTest, PrincipalDomainTestWithInvalidSpiffePrefixOnly) { + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanPeerCertificate()) + .WillOnce(Return(std::vector{"spiffe:/istio2.io"})); + EXPECT_EQ(conn_context_.principalDomain(true).value(), "spiffe:/istio2.io"); + + EXPECT_CALL(connection_, ssl()).WillOnce(Return(ssl_conn_info_)); + EXPECT_CALL(*ssl_conn_info_, uriSanLocalCertificate()) + .WillOnce(Return(std::vector{"spiffe:/istio2.io"})); + EXPECT_EQ(conn_context_.principalDomain(false).value(), "spiffe:/istio2.io"); +} + +} // namespace +} // namespace AuthN +} // namespace Extensions \ No newline at end of file diff --git a/extensions/authn/filter_context.cc b/extensions/authn/filter_context.cc new file mode 100644 index 00000000000..f28bbe4c6e0 --- /dev/null +++ b/extensions/authn/filter_context.cc @@ -0,0 +1,120 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/filter_context.h" + +#include "absl/strings/str_cat.h" +#include "src/envoy/utils/filter_names.h" + +using istio::authn::Payload; +using istio::authn::Result; + +namespace Extensions { +namespace AuthN { + +using Envoy::Extensions::HttpFilters::HttpFilterNames; +using Envoy::Utils::IstioFilterName; +using google::protobuf::util::MessageToJsonString; + +FilterContext::FilterContext( + const envoy::config::core::v3::Metadata& dynamic_metadata, + const RequestHeaderMap& header_map, + const ConnectionContextPtr connection_context, + const istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig& + filter_config) + : dynamic_metadata_(dynamic_metadata), + header_map_(header_map), + connection_context_(connection_context), + filter_config_(filter_config) {} + +void FilterContext::setOriginResult(const Payload* payload) { + // Authentication pass, look at the return payload and store to the context + // output. Set filter to continueDecoding when done. + // At the moment, only JWT can be used for origin authentication, so + // it's ok just to check jwt payload. + if (payload != nullptr && payload->has_jwt()) { + *result_.mutable_origin() = payload->jwt(); + } +} + +void FilterContext::setPeerAuthenticationResult(const Payload* payload) { + if (payload != nullptr && payload->has_x509()) { + result_.set_peer_user(payload->x509().user()); + } +} + +bool FilterContext::getJwtPayload(const std::string& issuer, + std::string* payload) const { + // Prefer to use the jwt payload from Envoy jwt filter over the Istio jwt + // filter's one. + return getJwtPayloadFromEnvoyJwtFilter(issuer, payload) || + getJwtPayloadFromIstioJwtFilter(issuer, payload); +} + +bool FilterContext::getJwtPayloadFromEnvoyJwtFilter( + const std::string& issuer, std::string* payload) const { + // Try getting the Jwt payload from Envoy jwt_authn filter. + auto filter_it = + dynamic_metadata_.filter_metadata().find(HttpFilterNames::get().JwtAuthn); + if (filter_it == dynamic_metadata_.filter_metadata().end()) { + return false; + } + + const auto& data_struct = filter_it->second; + + const auto entry_it = data_struct.fields().find(issuer); + if (entry_it == data_struct.fields().end()) { + return false; + } + + if (entry_it->second.struct_value().fields().empty()) { + return false; + } + + // Serialize the payload from Envoy jwt filter first before writing it to + // |payload|. + // TODO (pitlv2109): Return protobuf Struct instead of string, once Istio jwt + // filter is removed. Also need to change how Istio authn filter processes the + // jwt payload. + MessageToJsonString(entry_it->second.struct_value(), payload); + return true; +} + +bool FilterContext::getJwtPayloadFromIstioJwtFilter( + const std::string& issuer, std::string* payload) const { + // Try getting the Jwt payload from Istio jwt-auth filter. + auto filter_it = + dynamic_metadata_.filter_metadata().find(IstioFilterName::kJwt); + if (filter_it == dynamic_metadata_.filter_metadata().end()) { + return false; + } + + const auto& data_struct = filter_it->second; + + const auto entry_it = data_struct.fields().find(issuer); + if (entry_it == data_struct.fields().end()) { + return false; + } + + if (entry_it->second.string_value().empty()) { + return false; + } + + *payload = entry_it->second.string_value(); + return true; +} + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/filter_context.h b/extensions/authn/filter_context.h new file mode 100644 index 00000000000..2a5be9bb70e --- /dev/null +++ b/extensions/authn/filter_context.h @@ -0,0 +1,103 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "envoy/config/core/v3/base.pb.h" +#include "envoy/config/filter/http/authn/v2alpha2/config.pb.h" +#include "envoy/network/connection.h" +#include "extensions/authn/connection_context.h" +#include "extensions/filters/http/well_known_names.h" +#include "src/istio/authn/context.pb.h" + +namespace Extensions { +namespace AuthN { + +using Envoy::Http::RequestHeaderMap; +using Envoy::Network::Connection; + +// FilterContext holds inputs, such as request dynamic metadata and connection +// and result data for authentication process. +class FilterContext { + public: + FilterContext( + const envoy::config::core::v3::Metadata& dynamic_metadata, + const RequestHeaderMap& header_map, + const ConnectionContextPtr connection_context, + const istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig& + filter_config); + + virtual ~FilterContext() {} + + // Sets origin result based on authenticated payload. Input payload can be + // null, which basically changes nothing. + void setOriginResult(const istio::authn::Payload* payload); + + // Sets peer authentication result based on authenticated payload. Input + // payload can be null, which basically changes nothing. + void setPeerAuthenticationResult(const istio::authn::Payload* payload); + + // Gets JWT payload (output from JWT filter) for given issuer. If non-empty + // payload found, returns true and set the output payload string. Otherwise, + // returns false. + bool getJwtPayload(const std::string& issuer, std::string* payload) const; + + // Returns the authentication result. + const istio::authn::Result& authenticationResult() { return result_; } + + // Accessor to connection + const ConnectionContextPtr connectionContext() { return connection_context_; } + + const RequestHeaderMap& headerMap() const { return header_map_; } + + const istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig& + filterConfig() { + return filter_config_; + } + + private: + // Helper function for getJwtPayload(). It gets the jwt payload from Envoy jwt + // filter metadata and write to |payload|. + bool getJwtPayloadFromEnvoyJwtFilter(const std::string& issuer, + std::string* payload) const; + + // Helper function for getJwtPayload(). It gets the jwt payload from Istio jwt + // filter metadata and write to |payload|. + bool getJwtPayloadFromIstioJwtFilter(const std::string& issuer, + std::string* payload) const; + + // Const reference to request info dynamic metadata. This provides data that + // output from other filters, e.g JWT. + const envoy::config::core::v3::Metadata& dynamic_metadata_; + + // Const reference to header map of the request. This provides request path + // that could be used to decide if a JWT should be used for validation. + const RequestHeaderMap& header_map_; + + // Connection context + const ConnectionContextPtr connection_context_; + + // Holds authentication attribute outputs. + istio::authn::Result result_; + + // Store the Istio authn filter config. + const istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig& + filter_config_; +}; + +using FilterContextPtr = std::shared_ptr; + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/filter_context_test.cc b/extensions/authn/filter_context_test.cc new file mode 100644 index 00000000000..1909b62e0ca --- /dev/null +++ b/extensions/authn/filter_context_test.cc @@ -0,0 +1,57 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/filter_context.h" + +#include "envoy/config/core/v3/base.pb.h" +#include "extensions/authn/test_utils.h" +#include "test/test_common/utility.h" + +using istio::authn::Payload; +using testing::StrictMock; + +namespace Extensions { +namespace AuthN { +namespace { + +class FilterContextTest : public testing::Test { + public: + virtual ~FilterContextTest() {} + + envoy::config::core::v3::Metadata metadata_; + Envoy::Http::TestRequestHeaderMapImpl header_{}; + // This test suit does not use connection, so ok to use null for it. + FilterContext filter_context_{metadata_, header_, nullptr, + istio::envoy::config::filter::http::authn:: + v2alpha2::FilterConfig::default_instance()}; + + Payload jwt_payload_{TestUtilities::CreateJwtPayload("bar", "istio.io")}; +}; + +TEST_F(FilterContextTest, SetOriginResult) { + filter_context_.setOriginResult(&jwt_payload_); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(R"( + origin { + user: "bar" + presenter: "istio.io" + } + )"), + filter_context_.authenticationResult())); +} + +} // namespace +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/peer_authenticator.cc b/extensions/authn/peer_authenticator.cc new file mode 100644 index 00000000000..423cfc80715 --- /dev/null +++ b/extensions/authn/peer_authenticator.cc @@ -0,0 +1,103 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/peer_authenticator.h" + +namespace Extensions { +namespace AuthN { + +PeerAuthenticatorImpl::PeerAuthenticatorImpl( + FilterContextPtr filter_context, + const istio::security::v1beta1::PeerAuthentication& policy) + : peer_authentication_policy_(policy), filter_context_(filter_context) {} + +bool PeerAuthenticatorImpl::validateX509( + istio::authn::X509Payload* payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS& + mtls_policy) { + if (mtls_policy.mode() == + istio::security::v1beta1::PeerAuthentication::MutualTLS::DISABLE) { + return true; + } + + const auto principal_domain = + filter_context_->connectionContext()->principalDomain(true); + const bool has_user = filter_context_->connectionContext()->isMutualTls() && + principal_domain.has_value(); + + if (!has_user) { + switch (mtls_policy.mode()) { + case istio::security::v1beta1::PeerAuthentication::MutualTLS::UNSET: + case istio::security::v1beta1::PeerAuthentication::MutualTLS::PERMISSIVE: + return true; + case istio::security::v1beta1::PeerAuthentication::MutualTLS::STRICT: + return false; + default: + NOT_REACHED_GCOVR_EXCL_LINE; + } + } + + payload->set_user(principal_domain.value()); + + return validateTrustDomain(); +} + +bool PeerAuthenticatorImpl::run(istio::authn::Payload* payload) { + const auto local_port = filter_context_->connectionContext()->port(); + const auto port_level_mtls = peer_authentication_policy_.port_level_mtls(); + + if (local_port.has_value()) { + const auto mtls_policy = port_level_mtls.find(local_port.value()); + if (mtls_policy != port_level_mtls.end()) { + if (validateX509(payload->mutable_x509(), mtls_policy->second)) { + filter_context_->setPeerAuthenticationResult(payload); + return true; + } + + return false; + } + } + + if (validateX509(payload->mutable_x509(), + peer_authentication_policy_.mtls())) { + filter_context_->setPeerAuthenticationResult(payload); + return true; + } + + return false; +} + +bool PeerAuthenticatorImpl::validateTrustDomain() { + const auto peer_trust_domain = + filter_context_->connectionContext()->trustDomain(true); + if (!peer_trust_domain.has_value()) { + return false; + } + + const auto local_trust_domain = + filter_context_->connectionContext()->trustDomain(false); + if (!local_trust_domain.has_value()) { + return false; + } + + if (peer_trust_domain.value() != local_trust_domain.value()) { + return false; + } + + return true; +} + +} // namespace AuthN +} // namespace Extensions \ No newline at end of file diff --git a/extensions/authn/peer_authenticator.h b/extensions/authn/peer_authenticator.h new file mode 100644 index 00000000000..ba76f496ba8 --- /dev/null +++ b/extensions/authn/peer_authenticator.h @@ -0,0 +1,70 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "extensions/authn/authn_utils.h" +#include "extensions/authn/connection_context.h" +#include "extensions/authn/filter_context.h" +#include "security/v1beta1/peer_authentication.pb.h" +#include "src/istio/authn/context.pb.h" + +namespace Extensions { +namespace AuthN { + +class PeerAuthenticator { + public: + virtual ~PeerAuthenticator() = default; + + // Validate TLS/MTLS connection and extract authenticated attributes (just + // source user identity for now). Unlike mTLS, TLS connection does not require + // a client certificate.. + virtual bool validateX509( + istio::authn::X509Payload* payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS& + mtls_policy) PURE; +}; + +// PeerAuthenticator performs mTLS authentication for given credential +// rule. +class PeerAuthenticatorImpl : public PeerAuthenticator { + public: + PeerAuthenticatorImpl( + FilterContextPtr filter_context, + const istio::security::v1beta1::PeerAuthentication& policy); + + // IRequestAuthenticator + bool validateX509( + istio::authn::X509Payload* payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS& + mtls_policy) override; + + // Perform authentication. + bool run(istio::authn::Payload* payload); + + private: + bool validateTrustDomain(); + + // Reference to the authentication policy that the authenticator should + // enforce. Typically, the actual object is owned by filter. + const istio::security::v1beta1::PeerAuthentication + peer_authentication_policy_; + + // Pointer to filter state. Do not own. + FilterContextPtr filter_context_; +}; + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/peer_authenticator_test.cc b/extensions/authn/peer_authenticator_test.cc new file mode 100644 index 00000000000..75f3a2e0410 --- /dev/null +++ b/extensions/authn/peer_authenticator_test.cc @@ -0,0 +1,291 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/peer_authenticator.h" + +#include "extensions/authn/test_utils.h" +#include "gtest/gtest.h" +#include "test/test_common/utility.h" + +using ::google::protobuf::util::MessageDifferencer; +using testing::Invoke; +using testing::Return; + +namespace Extensions { +namespace AuthN { +namespace { + +namespace { +using istio::security::v1beta1::PeerAuthentication; + +static PeerAuthentication::MutualTLS::Mode disable = + PeerAuthentication::MutualTLS::DISABLE; +static PeerAuthentication::MutualTLS::Mode strict = + PeerAuthentication::MutualTLS::STRICT; +static PeerAuthentication::MutualTLS::Mode permissive = + PeerAuthentication::MutualTLS::PERMISSIVE; +} // namespace + +class MockConnectionContext : public ConnectionContext { + public: + MOCK_METHOD(absl::optional, trustDomain, (bool)); + MOCK_METHOD(absl::optional, principalDomain, (bool)); + MOCK_METHOD(bool, isMutualTls, (), (const)); + MOCK_METHOD(absl::optional, port, (), (const)); +}; + +class ValidateX509Test : public testing::Test { + public: + void setMtlsMode( + istio::security::v1beta1::PeerAuthentication::MutualTLS::Mode mode) { + peer_authentication_policy_.mutable_mtls()->set_mode(mode); + } + + void initialize() { + filter_context_.reset(); + filter_context_ = std::make_unique( + envoy::config::core::v3::Metadata::default_instance(), + Envoy::Http::TestRequestHeaderMapImpl(), connection_context_, + istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig:: + default_instance()); + + authenticator_.reset(); + authenticator_ = std::make_unique( + filter_context_, peer_authentication_policy_); + } + + protected: + std::unique_ptr authenticator_; + FilterContextPtr filter_context_; + istio::security::v1beta1::PeerAuthentication peer_authentication_policy_; + istio::authn::X509Payload result_payload_; + std::shared_ptr connection_context_{ + std::make_shared()}; +}; + +TEST_F(ValidateX509Test, EmptyPolicy) { + initialize(); + + // When there is no specified policy. It will be choiced UNSET as MutualTLS + // policy. It will behave as if PERMISSIVE was specified. + EXPECT_CALL(*connection_context_, principalDomain(true)) + .WillOnce(Return(absl::nullopt)); + EXPECT_CALL(*connection_context_, isMutualTls()).WillOnce(Return(true)); + EXPECT_TRUE(authenticator_->validateX509(&result_payload_, + peer_authentication_policy_.mtls())); +} + +TEST_F(ValidateX509Test, DisabledMutualTls) { + setMtlsMode(disable); + initialize(); + EXPECT_TRUE(authenticator_->validateX509(&result_payload_, + peer_authentication_policy_.mtls())); +} + +TEST_F(ValidateX509Test, NoUserStrictMutualTls) { + setMtlsMode(strict); + initialize(); + + EXPECT_CALL(*connection_context_, principalDomain(true)) + .WillOnce(Return(absl::nullopt)); + EXPECT_CALL(*connection_context_, isMutualTls()).WillOnce(Return(true)); + EXPECT_FALSE(authenticator_->validateX509( + &result_payload_, peer_authentication_policy_.mtls())); +} + +TEST_F(ValidateX509Test, MutualTlsWithPeerUser) { + setMtlsMode(strict); + initialize(); + + EXPECT_CALL(*connection_context_, principalDomain(true)) + .WillOnce(Return("istio.io")); + EXPECT_CALL(*connection_context_, isMutualTls()).WillOnce(Return(true)); + + // Has same trust domain between peer and local. + EXPECT_CALL(*connection_context_, trustDomain(true)) + .WillOnce(Return("istio2.io")); + EXPECT_CALL(*connection_context_, trustDomain(false)) + .WillOnce(Return("istio2.io")); + + EXPECT_TRUE(authenticator_->validateX509(&result_payload_, + peer_authentication_policy_.mtls())); + EXPECT_EQ("istio.io", result_payload_.user()); + + // Permissive mode with peer user + setMtlsMode(permissive); + initialize(); + + EXPECT_CALL(*connection_context_, principalDomain(true)) + .WillOnce(Return("istio.io")); + EXPECT_CALL(*connection_context_, isMutualTls()).WillOnce(Return(true)); + + // Has different trust domain between peer and local. + EXPECT_CALL(*connection_context_, trustDomain(true)) + .WillOnce(Return("istio2.io")); + EXPECT_CALL(*connection_context_, trustDomain(false)) + .WillOnce(Return("istio3.io")); + + EXPECT_FALSE(authenticator_->validateX509( + &result_payload_, peer_authentication_policy_.mtls())); + EXPECT_EQ("istio.io", result_payload_.user()); +} + +TEST_F(ValidateX509Test, NoUserPermissiveMutualTls) { + setMtlsMode(permissive); + initialize(); + + EXPECT_CALL(*connection_context_, principalDomain(true)) + .WillOnce(Return(absl::nullopt)); + EXPECT_CALL(*connection_context_, isMutualTls()).WillOnce(Return(true)); + EXPECT_TRUE(authenticator_->validateX509(&result_payload_, + peer_authentication_policy_.mtls())); +} + +class MockPeerAuthenticator : public PeerAuthenticatorImpl { + public: + MockPeerAuthenticator( + FilterContextPtr filter_context, + const istio::security::v1beta1::PeerAuthentication& policy) + : PeerAuthenticatorImpl(filter_context, policy) {} + + MOCK_METHOD(bool, validateX509, + (istio::authn::X509Payload * payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS& + mtls_policy)); +}; + +class PeerAuthenticatorTest : public testing::Test { + public: + void initialize() { + filter_context_.reset(); + filter_context_ = std::make_unique( + envoy::config::core::v3::Metadata::default_instance(), + Envoy::Http::TestRequestHeaderMapImpl(), connection_context_, + istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig:: + default_instance()); + + authenticator_.reset(); + authenticator_ = std::make_unique( + filter_context_, peer_authentication_policy_); + } + + void setMtlsMode( + istio::security::v1beta1::PeerAuthentication::MutualTLS::Mode mode) { + peer_authentication_policy_.mutable_mtls()->set_mode(mode); + } + + void setPortLevalMtls( + uint32_t port, + istio::security::v1beta1::PeerAuthentication::MutualTLS::Mode mode) { + istio::security::v1beta1::PeerAuthentication::MutualTLS mtls_config; + mtls_config.set_mode(mode); + (*peer_authentication_policy_.mutable_port_level_mtls())[port] = + mtls_config; + } + + protected: + std::unique_ptr authenticator_; + istio::authn::Payload result_payload_; + + FilterContextPtr filter_context_; + istio::security::v1beta1::PeerAuthentication peer_authentication_policy_; + std::shared_ptr connection_context_{ + std::make_shared()}; +}; + +TEST_F(PeerAuthenticatorTest, EmptyPolicy) { + initialize(); + EXPECT_CALL(*connection_context_, port()).WillOnce(Return(5000)); + EXPECT_CALL(*authenticator_, validateX509(_, _)).WillOnce(Return(false)); + + EXPECT_FALSE(authenticator_->run(&result_payload_)); +} + +TEST_F(PeerAuthenticatorTest, NoPortLevelPolicy) { + initialize(); + + EXPECT_CALL(*connection_context_, port()).WillOnce(Return(5000)); + EXPECT_CALL(*authenticator_, validateX509(result_payload_.mutable_x509(), _)) + .WillOnce(Invoke( + [](istio::authn::X509Payload* payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS&) { + payload->set_user("foo"); + return true; + })); + + EXPECT_TRUE(authenticator_->run(&result_payload_)); + EXPECT_EQ("foo", filter_context_->authenticationResult().peer_user()); +} + +MATCHER_P(MtlsPolicyEq, rhs, "") { return arg.mode() == rhs.mode(); } + +TEST_F(PeerAuthenticatorTest, BasicPortLevelPolicyTest) { + setPortLevalMtls(5000, strict); + initialize(); + + EXPECT_CALL(*connection_context_, port()).WillOnce(Return(5000)); + EXPECT_CALL( + *authenticator_, + validateX509( + result_payload_.mutable_x509(), + MtlsPolicyEq(peer_authentication_policy_.port_level_mtls().at(5000)))) + .WillOnce(Invoke( + [](istio::authn::X509Payload* payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS&) { + payload->set_user("foo"); + return true; + })); + + EXPECT_TRUE(authenticator_->run(&result_payload_)); + EXPECT_EQ("foo", filter_context_->authenticationResult().peer_user()); +} + +TEST_F(PeerAuthenticatorTest, PortLevelPeerAuthenticationFailed) { + setPortLevalMtls(5000, strict); + initialize(); + + EXPECT_CALL(*connection_context_, port()).WillOnce(Return(5000)); + EXPECT_CALL( + *authenticator_, + validateX509( + result_payload_.mutable_x509(), + MtlsPolicyEq(peer_authentication_policy_.port_level_mtls().at(5000)))) + .WillOnce(Return(false)); + + EXPECT_FALSE(authenticator_->run(&result_payload_)); +} + +TEST_F(PeerAuthenticatorTest, PortLevelPeerAuthenticationNotFound) { + setPortLevalMtls(8000, strict); + initialize(); + + EXPECT_CALL(*connection_context_, port()).WillOnce(Return(5000)); + EXPECT_CALL(*authenticator_, + validateX509(result_payload_.mutable_x509(), + MtlsPolicyEq(peer_authentication_policy_.mtls()))) + .WillOnce(Invoke( + [](istio::authn::X509Payload* payload, + const istio::security::v1beta1::PeerAuthentication::MutualTLS&) { + payload->set_user("foo"); + return true; + })); + + EXPECT_TRUE(authenticator_->run(&result_payload_)); + EXPECT_EQ("foo", filter_context_->authenticationResult().peer_user()); +} + +} // namespace +} // namespace AuthN +} // namespace Extensions \ No newline at end of file diff --git a/extensions/authn/request_authenticator.cc b/extensions/authn/request_authenticator.cc new file mode 100644 index 00000000000..2b6501fd993 --- /dev/null +++ b/extensions/authn/request_authenticator.cc @@ -0,0 +1,122 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/request_authenticator.h" + +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "common/http/headers.h" + +using istio::authn::Payload; + +namespace Extensions { +namespace AuthN { + +namespace { +// The default header name for an exchanged token +static const std::string kExchangedTokenHeaderName = "ingress-authorization"; + +// Returns whether the header for an exchanged token is found +bool FindHeaderOfExchangedToken( + const istio::security::v1beta1::JWTRule& jwt_rule) { + return (jwt_rule.from_headers_size() == 1 && + Envoy::Http::LowerCaseString(kExchangedTokenHeaderName) == + Envoy::Http::LowerCaseString(jwt_rule.from_headers(0).name())); +} +} // namespace + +Envoy::Http::RegisterCustomInlineHeader< + Envoy::Http::CustomInlineHeaderRegistry::Type::RequestHeaders> + access_control_request_method_handle( + Envoy::Http::CustomHeaders::get().AccessControlRequestMethod); +Envoy::Http::RegisterCustomInlineHeader< + Envoy::Http::CustomInlineHeaderRegistry::Type::RequestHeaders> + origin_handle(Envoy::Http::CustomHeaders::get().Origin); + +bool isCORSPreflightRequest(const Envoy::Http::RequestHeaderMap& headers) { + return headers.Method() && + headers.Method()->value().getStringView() == + Envoy::Http::Headers::get().MethodValues.Options && + !headers.getInlineValue(origin_handle.handle()).empty() && + !headers.getInlineValue(access_control_request_method_handle.handle()) + .empty(); +} + +RequestAuthenticator::RequestAuthenticator( + FilterContextPtr filter_context, + const istio::security::v1beta1::RequestAuthentication& policy) + : request_authentication_policy_(policy), filter_context_(filter_context) {} + +bool RequestAuthenticator::run(Payload* payload) { + if (isCORSPreflightRequest(filter_context_->headerMap())) { + // The CORS preflight doesn't include user credentials, allow regardless of + // JWT policy. See + // http://www.w3.org/TR/cors/#cross-origin-request-with-preflight. + return true; + } + + absl::string_view path; + if (filter_context_->headerMap().Path() != nullptr) { + path = filter_context_->headerMap().Path()->value().getStringView(); + + // Trim query parameters and/or fragment if present + size_t offset = path.find_first_of("?#"); + if (offset != absl::string_view::npos) { + path.remove_suffix(path.length() - offset); + } + } + + if (payload != nullptr && + payload->payload_case() == Payload::PayloadCase::kJwt) { + if (validateJwt(payload->mutable_jwt())) { + filter_context_->setOriginResult(payload); + return true; + } + } + + return false; +} + +bool RequestAuthenticator::validateJwt(istio::authn::JwtPayload* jwt) { + for (const auto& jwt_rule : request_authentication_policy_.jwt_rules()) { + std::string jwt_payload; + if (!filter_context_->getJwtPayload(jwt_rule.issuer(), &jwt_payload)) { + continue; + } + + std::string payload_to_process = jwt_payload; + std::string original_payload; + if (FindHeaderOfExchangedToken(jwt_rule)) { + if (!AuthnUtils::ExtractOriginalPayload(jwt_payload, &original_payload)) { + // When the header of an exchanged token is found but the token + // does not contain the claim of the original payload, it + // is regarded as an invalid exchanged token. + continue; + } + // When the header of an exchanged token is found and the token + // contains the claim of the original payload, the original payload + // is extracted and used as the token payload. + payload_to_process = original_payload; + } + + if (AuthnUtils::ProcessJwtPayload(payload_to_process, jwt)) { + return true; + } + } + return false; +} + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/request_authenticator.h b/extensions/authn/request_authenticator.h new file mode 100644 index 00000000000..94e80e31c7f --- /dev/null +++ b/extensions/authn/request_authenticator.h @@ -0,0 +1,61 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "extensions/authn/authn_utils.h" +#include "extensions/authn/filter_context.h" +#include "security/v1beta1/request_authentication.pb.h" +#include "src/istio/authn/context.pb.h" + +namespace Extensions { +namespace AuthN { + +class IRequestAuthenticator { + public: + virtual ~IRequestAuthenticator() = default; + + // Validates JWT given the jwt params. If JWT is validated, it will extract + // attributes and claims (JwtPayload), returns status SUCCESS. + // Otherwise, returns status FAILED. + virtual bool validateJwt(istio::authn::JwtPayload* jwt) PURE; +}; + +// RequestAuthenticator performs origin authentication for given credential +// rule. +class RequestAuthenticator : public IRequestAuthenticator { + public: + RequestAuthenticator( + FilterContextPtr filter_context, + const istio::security::v1beta1::RequestAuthentication& policy); + + // IRequestAuthenticator + bool validateJwt(istio::authn::JwtPayload* jwt) override; + + // Perform authentication. + bool run(istio::authn::Payload* payload); + + private: + // Reference to the authentication policy that the authenticator should + // enforce. Typically, the actual object is owned by filter. + const istio::security::v1beta1::RequestAuthentication + request_authentication_policy_; + + // Pointer to filter state. Do not own. + FilterContextPtr filter_context_; +}; + +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/request_authenticator_test.cc b/extensions/authn/request_authenticator_test.cc new file mode 100644 index 00000000000..60ca0b911f6 --- /dev/null +++ b/extensions/authn/request_authenticator_test.cc @@ -0,0 +1,442 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "extensions/authn/request_authenticator.h" + +#include "common/protobuf/protobuf.h" +#include "envoy/config/core/v3/base.pb.h" +#include "extensions/authn/test_utils.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "security/v1beta1/request_authentication.pb.h" +#include "test/mocks/http/mocks.h" +#include "test/test_common/utility.h" + +using google::protobuf::util::MessageDifferencer; +using istio::authn::Result; +using testing::Return; + +namespace Extensions { +namespace AuthN { +namespace { + +static constexpr absl::string_view kExchangedTokenHeaderName = + "ingress-authorization"; + +static constexpr absl::string_view kExchangedTokenOriginalPayload = + "original_claims"; + +static constexpr absl::string_view kSecIstioAuthUserinfoHeaderValue = + R"( + { + "iss": "issuer@foo.com", + "sub": "sub@foo.com", + "aud": ["aud1", "aud2"], + "non-string-will-be-ignored": 1512754205, + "some-other-string-claims": "some-claims-kept" + } + )"; + +static constexpr absl::string_view kExchangedTokenPayload = + R"( + { + "iss": "token-service", + "sub": "subject", + "aud": ["aud1", "aud2"], + "original_claims": { + "iss": "https://accounts.example.com", + "sub": "example-subject", + "email": "user@example.com" + } + } + )"; + +static constexpr absl::string_view kExchangedTokenPayloadNoOriginalClaims = + R"( + { + "iss": "token-service", + "sub": "subject", + "aud": ["aud1", "aud2"] + } + )"; + +class ValidateJwtTest : public testing::Test { + public: + virtual ~ValidateJwtTest() {} + + void addJwtRule(istio::security::v1beta1::JWTRule& rule) { + request_authentication_policy_.mutable_jwt_rules()->Add(std::move(rule)); + } + + void createAuthenticator() { + authenticator_.reset(); + authenticator_ = std::make_unique( + filter_context_, request_authentication_policy_); + } + + void createFilterContext() { + filter_context_.reset(); + filter_context_ = std::make_unique( + dynamic_metadata_, header_, nullptr, + istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig:: + default_instance()); + } + + void addEnvoyFilterMetadata(Envoy::ProtobufWkt::Struct& message) { + (*dynamic_metadata_.mutable_filter_metadata()) + [Envoy::Extensions::HttpFilters::HttpFilterNames::get().JwtAuthn] + .MergeFrom(message); + } + + void checkResultPayload() { + // Only to check result_payload_.raw_claims, which should be the same to + // passed JWT payload, which is like kSecIstioAuthUserinfoHeaderValue. + Envoy::ProtobufWkt::Struct result_payload_raw_claims; + JsonStringToMessage(result_payload_.raw_claims(), + &result_payload_raw_claims, + google::protobuf::util::JsonParseOptions{}); + + auto jwt_payload_fields = jwt_payload_.fields(); + if (expect_token_exchanged_ && + jwt_payload_fields.find(kExchangedTokenOriginalPayload.data()) != + jwt_payload_fields.end()) { + EXPECT_TRUE(MessageDifferencer::Equals( + result_payload_raw_claims, + jwt_payload_fields.at(kExchangedTokenOriginalPayload.data()) + .struct_value())); + } else { + EXPECT_TRUE( + MessageDifferencer::Equals(result_payload_raw_claims, jwt_payload_)); + } + + // Next, check fields which except raw_claims that already checked. + // Because expected_payload_ is not expected to have raw_claims, it cuts + // raw_claims from result_payload. + ASSERT(expected_payload_.raw_claims().empty()); + result_payload_.clear_raw_claims(); + EXPECT_TRUE(MessageDifferencer::Equals(result_payload_, expected_payload_)); + } + + void initialize() { + createFilterContext(); + createAuthenticator(); + } + + protected: + std::unique_ptr authenticator_; + istio::security::v1beta1::RequestAuthentication + request_authentication_policy_; + Envoy::ProtobufWkt::Struct jwt_payload_; + istio::authn::JwtPayload result_payload_; + istio::authn::JwtPayload expected_payload_; + envoy::config::core::v3::Metadata dynamic_metadata_; + Envoy::Http::TestRequestHeaderMapImpl header_; + FilterContextPtr filter_context_; + bool expect_token_exchanged_{false}; +}; + +TEST_F(ValidateJwtTest, NoIstioAuthnConfig) { + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("issuer@foo.com"); + addJwtRule(jwt_rule); + initialize(); + + // authenticator_ has empty Istio authn config + // When there is empty Istio authn config, validateJwt() should return + // nullptr and failure. + EXPECT_FALSE(authenticator_->validateJwt(&result_payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(result_payload_, expected_payload_)); +} + +TEST_F(ValidateJwtTest, NoIssuer) { + // no issuer in jwt + initialize(); + + // When there is no issuer in the JWT config, validateJwt() should return + // nullptr and failure. + EXPECT_FALSE(authenticator_->validateJwt(&result_payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(result_payload_, expected_payload_)); +} + +TEST_F(ValidateJwtTest, HasJwtPayloadOutputButNoDataForIssuer) { + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("issuer@foo.com"); + addJwtRule(jwt_rule); + auto filter_metadata = Envoy::MessageUtil::keyValueStruct("foo", "bar"); + addEnvoyFilterMetadata(filter_metadata); + initialize(); + + // When there is no JWT payload for given issuer in request info dynamic + // metadata, validateJwt() should return nullptr and failure. + EXPECT_FALSE(authenticator_->validateJwt(&result_payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(result_payload_, expected_payload_)); +} + +TEST_F(ValidateJwtTest, HasJwtPayloadOutPutButWithInvalidData) { + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("issuer@foo.com"); + addJwtRule(jwt_rule); + auto filter_metadata = + Envoy::MessageUtil::keyValueStruct("issuer@foo.com", "bar"); + addEnvoyFilterMetadata(filter_metadata); + initialize(); + + EXPECT_FALSE(authenticator_->validateJwt(&result_payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(result_payload_, expected_payload_)); +} + +TEST_F(ValidateJwtTest, MultipleJwtRulesWithValidJwt) { + std::array issuer{"issuer2@foo.com", "issuer1@foo.com", + "issuer@foo.com"}; + for (auto&& i : issuer) { + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer(i); + addJwtRule(jwt_rule); + } + JsonStringToMessage(kSecIstioAuthUserinfoHeaderValue.data(), &jwt_payload_, + google::protobuf::util::JsonParseOptions{}); + Envoy::ProtobufWkt::Struct payload_to_pass; + (*payload_to_pass.mutable_fields())["issuer@foo.com"] + .mutable_struct_value() + ->CopyFrom(jwt_payload_); + addEnvoyFilterMetadata(payload_to_pass); + initialize(); + + EXPECT_TRUE(authenticator_->validateJwt(&result_payload_)); +} + +TEST_F(ValidateJwtTest, MultipleJwtRulesWithInvalidJwt) { + std::array issuer{"issuer2@foo.com", "issuer1@foo.com", + "issuer@foo.com"}; + for (auto&& i : issuer) { + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer(i); + addJwtRule(jwt_rule); + } + JsonStringToMessage(kSecIstioAuthUserinfoHeaderValue.data(), &jwt_payload_, + google::protobuf::util::JsonParseOptions{}); + Envoy::ProtobufWkt::Struct payload_to_pass; + (*payload_to_pass.mutable_fields())["dummy@foo.com"] + .mutable_struct_value() + ->CopyFrom(jwt_payload_); + addEnvoyFilterMetadata(payload_to_pass); + initialize(); + + EXPECT_FALSE(authenticator_->validateJwt(&result_payload_)); +} + +TEST_F(ValidateJwtTest, HasJwtPayloadOutput) { + JsonStringToMessage(kSecIstioAuthUserinfoHeaderValue.data(), &jwt_payload_, + google::protobuf::util::JsonParseOptions{}); + Envoy::ProtobufWkt::Struct payload_to_pass; + (*payload_to_pass.mutable_fields())["issuer@foo.com"] + .mutable_struct_value() + ->CopyFrom(jwt_payload_); + + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("issuer@foo.com"); + addJwtRule(jwt_rule); + addEnvoyFilterMetadata(payload_to_pass); + + JsonStringToMessage( + R"( + { + "user": "issuer@foo.com/sub@foo.com", + "audiences": ["aud1", "aud2"], + "presenter": "", + "claims": { + "aud": ["aud1", "aud2"], + "iss": ["issuer@foo.com"], + "some-other-string-claims": ["some-claims-kept"], + "sub": ["sub@foo.com"], + } + } +)", + &expected_payload_, google::protobuf::util::JsonParseOptions{}); + initialize(); + + EXPECT_TRUE(authenticator_->validateJwt(&result_payload_)); + checkResultPayload(); +} + +TEST_F(ValidateJwtTest, HasJwtPayloadOutputWithTokenExchanges) { + JsonStringToMessage(kExchangedTokenPayload.data(), &jwt_payload_, + google::protobuf::util::JsonParseOptions{}); + Envoy::ProtobufWkt::Struct payload_to_pass; + (*payload_to_pass.mutable_fields())["token-service"] + .mutable_struct_value() + ->CopyFrom(jwt_payload_); + + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("token-service"); + istio::security::v1beta1::JWTHeader jwt_header; + jwt_header.set_name(kExchangedTokenHeaderName.data()); + jwt_header.set_prefix("Bearer "); + *jwt_rule.add_from_headers() = jwt_header; + addJwtRule(jwt_rule); + addEnvoyFilterMetadata(payload_to_pass); + expect_token_exchanged_ = true; + + JsonStringToMessage( + R"( + { + "user": "https://accounts.example.com/example-subject", + "claims": { + "iss": ["https://accounts.example.com"], + "sub": ["example-subject"], + "email": ["user@example.com"] + } + } +)", + &expected_payload_, google::protobuf::util::JsonParseOptions{}); + initialize(); + + EXPECT_TRUE(authenticator_->validateJwt(&result_payload_)); + checkResultPayload(); +} + +TEST_F(ValidateJwtTest, HasJwtPayloadOutputWithoutTokenExchanges) { + JsonStringToMessage(kExchangedTokenPayloadNoOriginalClaims.data(), + &jwt_payload_, + google::protobuf::util::JsonParseOptions{}); + Envoy::ProtobufWkt::Struct payload_to_pass; + (*payload_to_pass.mutable_fields())["token-service"] + .mutable_struct_value() + ->CopyFrom(jwt_payload_); + + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("token-service"); + istio::security::v1beta1::JWTHeader jwt_header; + jwt_header.set_name(kExchangedTokenHeaderName.data()); + jwt_header.set_prefix("Bearer "); + *jwt_rule.add_from_headers() = jwt_header; + addJwtRule(jwt_rule); + addEnvoyFilterMetadata(payload_to_pass); + initialize(); + + EXPECT_FALSE(authenticator_->validateJwt(&result_payload_)); +} + +TEST_F(ValidateJwtTest, + HasJwtPayloadOutputWithTokenExchangesAndNoExchangedTokenHeaderName) { + JsonStringToMessage(kExchangedTokenPayload.data(), &jwt_payload_, + google::protobuf::util::JsonParseOptions{}); + Envoy::ProtobufWkt::Struct payload_to_pass; + (*payload_to_pass.mutable_fields())["token-service"] + .mutable_struct_value() + ->CopyFrom(jwt_payload_); + + istio::security::v1beta1::JWTRule jwt_rule; + jwt_rule.set_issuer("token-service"); + addJwtRule(jwt_rule); + addEnvoyFilterMetadata(payload_to_pass); + + JsonStringToMessage( + R"( + { + "user": "token-service/subject", + "audiences": ["aud1", "aud2"], + "claims": { + "iss": ["token-service"], + "sub": ["subject"], + "aud": ["aud1", "aud2"] + } + } +)", + &expected_payload_, google::protobuf::util::JsonParseOptions{}); + initialize(); + + EXPECT_TRUE(authenticator_->validateJwt(&result_payload_)); + checkResultPayload(); +} + +static constexpr absl::string_view kSingleOriginMethodPolicy = R"( + jwt_rules { + issuer: "istio.io" + } +)"; + +class MockRequestAuthenticator : public RequestAuthenticator { + public: + MockRequestAuthenticator( + FilterContextPtr filter_context, + const istio::security::v1beta1::RequestAuthentication& policy); + MOCK_METHOD(bool, validateJwt, (istio::authn::JwtPayload*)); +}; + +MockRequestAuthenticator::MockRequestAuthenticator( + FilterContextPtr filter_context, + const istio::security::v1beta1::RequestAuthentication& policy) + : RequestAuthenticator(filter_context, policy) {} + +class RequestAuthenticatorTest : public testing::Test { + public: + RequestAuthenticatorTest() {} + virtual ~RequestAuthenticatorTest() {} + + void createAuthenticator() { + authenticator_.reset(); + authenticator_ = std::make_unique( + filter_context_, request_authentication_policy_); + } + + protected: + std::unique_ptr authenticator_; + envoy::config::core::v3::Metadata metadata_; + Envoy::Http::TestRequestHeaderMapImpl header_{}; + FilterContextPtr filter_context_{std::make_unique( + envoy::config::core::v3::Metadata::default_instance(), header_, nullptr, + istio::envoy::config::filter::http::authn::v2alpha2::FilterConfig:: + default_instance())}; + istio::security::v1beta1::RequestAuthentication + request_authentication_policy_; + istio::authn::Payload jwt_payload_; + Result expected_result_; +}; + +TEST_F(RequestAuthenticatorTest, Empty) { + createAuthenticator(); + + EXPECT_FALSE(authenticator_->run(&jwt_payload_)); + EXPECT_TRUE(MessageDifferencer::Equals( + expected_result_, filter_context_->authenticationResult())); +} + +TEST_F(RequestAuthenticatorTest, Pass) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodPolicy.data(), &request_authentication_policy_)); + jwt_payload_ = TestUtilities::CreateJwtPayload("foo", "istio.io"); + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_)).WillOnce(Return(true)); + EXPECT_TRUE(authenticator_->run(&jwt_payload_)); +} + +TEST_F(RequestAuthenticatorTest, CORSPreflight) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodPolicy.data(), &request_authentication_policy_)); + jwt_payload_ = TestUtilities::CreateJwtPayload("foo", "istio.io"); + createAuthenticator(); + + header_.addCopy(":method", "OPTIONS"); + header_.addCopy("origin", "example.com"); + header_.addCopy("access-control-request-method", "GET"); + + EXPECT_TRUE(authenticator_->run(&jwt_payload_)); +} + +} // namespace +} // namespace AuthN +} // namespace Extensions diff --git a/extensions/authn/test_utils.h b/extensions/authn/test_utils.h new file mode 100644 index 00000000000..6f329d920a0 --- /dev/null +++ b/extensions/authn/test_utils.h @@ -0,0 +1,50 @@ +/* Copyright 2020 Istio Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "common/protobuf/protobuf.h" +#include "gmock/gmock.h" +#include "src/istio/authn/context.pb.h" + +namespace Extensions { +namespace AuthN { +namespace TestUtilities { + +istio::authn::Payload CreateX509Payload(const std::string& user) { + istio::authn::Payload payload; + payload.mutable_x509()->set_user(user); + return payload; +} + +istio::authn::Payload CreateJwtPayload(const std::string& user, + const std::string& presenter) { + istio::authn::Payload payload; + payload.mutable_jwt()->set_user(user); + if (!presenter.empty()) { + payload.mutable_jwt()->set_presenter(presenter); + } + return payload; +} + +istio::authn::Result AuthNResultFromString(const std::string& text) { + istio::authn::Result result; + EXPECT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(text, &result)); + return result; +} + +} // namespace TestUtilities +} // namespace AuthN +} // namespace Extensions diff --git a/repositories.bzl b/repositories.bzl index 0be93c66000..810c0925aae 100644 --- a/repositories.bzl +++ b/repositories.bzl @@ -105,8 +105,8 @@ cc_library( # 1) find the ISTIO_API SHA you want in git # 2) wget https://github.com/istio/api/archive/$ISTIO_API_SHA.tar.gz && sha256sum $ISTIO_API_SHA.tar.gz # -ISTIO_API = "31d048906d97fb7f6b1fa8e250d3fa07456c5acc" -ISTIO_API_SHA256 = "5bf68ef13f4b9e769b7ca0a9ce83d9da5263eed9b1223c4cbb388a6ad5520e01" +ISTIO_API = "597c9cfc0332eb1411773ae0cc514ca617df18c2" +ISTIO_API_SHA256 = "586e5071f42c43f850a1271f7fbf705a797c5107fb61b3d2a5c13b6776d03388" GOGOPROTO_RELEASE = "1.2.1" GOGOPROTO_SHA256 = "99e423905ba8921e86817607a5294ffeedb66fdd4a85efce5eb2848f715fdb3a" @@ -152,6 +152,29 @@ cc_proto_library( ], ) +proto_library( + name = "security_authentication_config_proto_lib", + srcs = glob( + ["security/v1beta1/*.proto", + "envoy/config/filter/http/authn/v2alpha2/*.proto", + "type/v1beta1/*.proto", + ], + ), + visibility = ["//visibility:public"], + deps = [ + "@com_google_googleapis//google/api:field_behavior_proto", + "@com_github_gogo_protobuf//:gogo_proto", + ], +) + +cc_proto_library( + name = "security_authentication_config_cc_proto", + visibility = ["//visibility:public"], + deps = [ + ":security_authentication_config_proto_lib", + ], +) + proto_library( name = "alpn_filter_config_proto_lib", srcs = glob( @@ -255,6 +278,10 @@ py_proto_library( name = "authentication_policy_config_cc_proto", actual = "@istioapi_git//:authentication_policy_config_cc_proto", ) + native.bind( + name = "security_authentication_config_cc_proto", + actual = "@istioapi_git//:security_authentication_config_cc_proto", + ) native.bind( name = "alpn_filter_config_cc_proto", actual = "@istioapi_git//:alpn_filter_config_cc_proto",