diff --git a/WORKSPACE b/WORKSPACE index cffcfc04ffe..175c7be59bf 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -18,6 +18,7 @@ workspace(name = "io_istio_proxy") # http_archive is not a native function since bazel 0.19 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file") +load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") load( "//:repositories.bzl", "docker_dependencies", diff --git a/extensions/BUILD b/extensions/BUILD index 7341cc2bf28..17b6c13cbb5 100644 --- a/extensions/BUILD +++ b/extensions/BUILD @@ -47,6 +47,37 @@ wasm_cc_binary( ], ) +wasm_cc_binary( + name = "authn.wasm", + srcs = [ + "//extensions/authn:authenticator_base.h", + "//extensions/authn:authenticator_base.cc", + "//extensions/authn:authn_utils.h", + "//extensions/authn:authn_utils.cc", + "//extensions/authn:filter_context.h", + "//extensions/authn:filter_context.cc", + "//extensions/authn:origin_authenticator.h", + "//extensions/authn:origin_authenticator.cc", + "//extensions/authn:peer_authenticator.h", + "//extensions/authn:peer_authenticator.cc", + "//extensions/authn:plugin.h", + "//extensions/authn:plugin.cc", + ], + copts = ["-UNULL_PLUGIN"], + deps = [ + "//external:authentication_policy_config_cc_proto", + "//external:abseil_strings", + "//src/istio/authn:context_proto_cc_wasm", + "//src/envoy/utils:filter_names_lib", + "//src/envoy/utils:utils_lib_wasm", + "//src/envoy/http/jwt_auth:jwt_lib_wasm", + "@proxy_wasm_cpp_sdk//:proxy_wasm_intrinsics", + "@proxy_wasm_cpp_sdk//contrib:contrib_lib", + "@envoy//source/common/http:headers_lib", + "@envoy//source/extensions/filters/http:well_known_names", + ], +) + wasm_cc_binary( name = "metadata_exchange.wasm", srcs = [ @@ -56,12 +87,12 @@ wasm_cc_binary( "//extensions/common:proto_util.h", "//extensions/common:util.cc", "//extensions/common:util.h", - "//extensions/metadata_exchange:base64.h", "//extensions/metadata_exchange:plugin.cc", "//extensions/metadata_exchange:plugin.h", ], copts = ["-UNULL_PLUGIN"], deps = [ + "//extensions/common:base64_wasm", "//extensions/common:json_util_wasm", "//extensions/common:node_info_fb_cc", "//extensions/metadata_exchange:declare_property_proto_cc", diff --git a/extensions/access_log_policy/plugin.cc b/extensions/access_log_policy/plugin.cc index 6e50b63fe46..4dd82bf6699 100644 --- a/extensions/access_log_policy/plugin.cc +++ b/extensions/access_log_policy/plugin.cc @@ -25,7 +25,7 @@ #ifndef NULL_PLUGIN -#include "base64.h" +#include "extensions/common/base64.h" #else diff --git a/extensions/authn/BUILD b/extensions/authn/BUILD new file mode 100644 index 00000000000..5300c55e8aa --- /dev/null +++ b/extensions/authn/BUILD @@ -0,0 +1,155 @@ +# 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 = "authn_plugin", + srcs = [ + "authenticator_base.cc", + "authn_utils.cc", + "filter_context.cc", + "origin_authenticator.cc", + "peer_authenticator.cc", + # "plugin.cc", + ], + hdrs = [ + "authenticator_base.h", + "authn_utils.h", + "filter_context.h", + "origin_authenticator.h", + "peer_authenticator.h", + # "plugin.h", + ], + repository = "@envoy", + visibility = ["//visibility:public"], + deps = [ + "//extensions/common:context", + "//extensions/common:json_util", + "//external:authentication_policy_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", + "@proxy_wasm_cpp_host//:lib", + "@proxy_wasm_cpp_sdk//contrib:contrib_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 = "filter_context_test", + srcs = ["filter_context_test.cc"], + repository = "@envoy", + deps = [ + ":authn_plugin", + ":test_utils", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "authenticator_base_test", + srcs = ["authenticator_base_test.cc"], + repository = "@envoy", + deps = [ + ":authn_plugin", + ":test_utils", + "//src/envoy/utils:filter_names_lib", + "@envoy//test/mocks/network:network_mocks", + "@envoy//test/mocks/ssl:ssl_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "authn_utils_test", + srcs = ["authn_utils_test.cc"], + repository = "@envoy", + deps = [ + ":authn_plugin", + ":test_utils", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "peer_authenticator_test", + srcs = ["peer_authenticator_test.cc"], + repository = "@envoy", + deps = [ + ":authn_plugin", + ":test_utils", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "origin_authenticator_test", + srcs = ["origin_authenticator_test.cc"], + repository = "@envoy", + deps = [ + ":authn_plugin", + ":test_utils", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/test_common:utility_lib", + ], +) + +# envoy_cc_test( +# name = "http_filter_test", +# srcs = ["http_filter_test.cc"], +# repository = "@envoy", +# deps = [ +# ":filter_lib", +# ":test_utils", +# "//external:authentication_policy_config_cc_proto", +# "@envoy//source/common/http:header_map_lib", +# "@envoy//test/mocks/http:http_mocks", +# "@envoy//test/test_common:utility_lib", +# ], +# ) + +# envoy_cc_test( +# name = "http_filter_integration_test", +# srcs = ["http_filter_integration_test.cc"], +# data = glob(["testdata/*"]), +# repository = "@envoy", +# deps = [ +# ":filter_lib", +# "//src/envoy/utils:filter_names_lib", +# "@envoy//source/common/common:utility_lib", +# "@envoy//test/integration:http_protocol_integration_lib", +# ], +# ) diff --git a/extensions/authn/authenticator_base.cc b/extensions/authn/authenticator_base.cc new file mode 100644 index 00000000000..33e3d207171 --- /dev/null +++ b/extensions/authn/authenticator_base.cc @@ -0,0 +1,168 @@ +/* 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/authenticator_base.h" + +#include "absl/strings/str_cat.h" +#include "common/common/assert.h" +#include "extensions/authn/authn_utils.h" +#include "src/envoy/utils/filter_names.h" +#include "src/envoy/utils/utils.h" + +using istio::authn::Payload; + +namespace iaapi = istio::authentication::v1alpha1; + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +using proxy_wasm::null_plugin::logDebug; +using proxy_wasm::null_plugin::logError; +using proxy_wasm::null_plugin::logTrace; +using proxy_wasm::null_plugin::logWarn; + +#endif // NULL_PLUGIN + +using Envoy::Http::LowerCaseString; +using Envoy::Utils::GetPrincipal; +using Envoy::Utils::GetTrustDomain; + +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 iaapi::Jwt& jwt) { + return (jwt.jwt_headers_size() == 1 && + LowerCaseString(kExchangedTokenHeaderName) == + LowerCaseString(jwt.jwt_headers(0))); +} + +} // namespace + +AuthenticatorBase::AuthenticatorBase(FilterContext* filter_context) + : filter_context_(*filter_context) {} + +AuthenticatorBase::~AuthenticatorBase() {} + +bool AuthenticatorBase::validateTrustDomain( + const Connection* connection) const { + std::string peer_trust_domain; + if (!GetTrustDomain(connection, true, &peer_trust_domain)) { + logError("trust domain validation failed: cannot get peer trust domain"); + return false; + } + + std::string local_trust_domain; + if (!GetTrustDomain(connection, false, &local_trust_domain)) { + logError("trust domain validation failed: cannot get local trust domain"); + return false; + } + + if (peer_trust_domain != local_trust_domain) { + logError( + absl::StrCat("trust domain validation failed: peer trust domain {} " + "different from local trust domain {}", + peer_trust_domain, local_trust_domain)); + return false; + } + + logDebug("trust domain validation succeeded"); + return true; +} + +bool AuthenticatorBase::validateX509(const iaapi::MutualTls& mtls, + Payload* payload) const { + const Connection* connection = filter_context_.connection(); + if (connection == nullptr) { + // It's wrong if connection does not exist. + logError("validateX509 failed: null connection."); + return false; + } + // Always try to get principal and set to output if available. + const bool has_user = + connection->ssl() != nullptr && + connection->ssl()->peerCertificatePresented() && + GetPrincipal(connection, true, payload->mutable_x509()->mutable_user()); + logDebug(absl::StrCat("validateX509 mode {}: ssl={}, has_user={}", + iaapi::MutualTls::Mode_Name(mtls.mode()), + connection->ssl() != nullptr, has_user)); + + if (!has_user) { + // For plaintext connection, return value depend on mode: + // - PERMISSIVE: always true. + // - STRICT: always false. + switch (mtls.mode()) { + case iaapi::MutualTls::PERMISSIVE: + return true; + case iaapi::MutualTls::STRICT: + return false; + default: + NOT_REACHED_GCOVR_EXCL_LINE; + } + } + + if (filter_context_.filter_config().skip_validate_trust_domain()) { + logDebug("trust domain validation skipped"); + return true; + } + + // For TLS connection with valid certificate, validate trust domain for both + // PERMISSIVE and STRICT mode. + return validateTrustDomain(connection); +} + +bool AuthenticatorBase::validateJwt(const iaapi::Jwt& jwt, Payload* payload) { + std::string jwt_payload; + if (filter_context()->getJwtPayload(jwt.issuer(), &jwt_payload)) { + std::string payload_to_process = jwt_payload; + std::string original_payload; + if (FindHeaderOfExchangedToken(jwt)) { + if (AuthnUtils::ExtractOriginalPayload(jwt_payload, &original_payload)) { + // 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; + } else { + // 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. + logError(absl::StrCat( + "Expect exchanged-token with original payload claim. Received: {}", + jwt_payload)); + return false; + } + } + return AuthnUtils::ProcessJwtPayload(payload_to_process, + payload->mutable_jwt()); + } + return false; +} + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif diff --git a/extensions/authn/authenticator_base.h b/extensions/authn/authenticator_base.h new file mode 100644 index 00000000000..f980591bc99 --- /dev/null +++ b/extensions/authn/authenticator_base.h @@ -0,0 +1,77 @@ +/* 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 "authentication/v1alpha1/policy.pb.h" +#include "extensions/authn/filter_context.h" +#include "src/istio/authn/context.pb.h" + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +using Envoy::Network::Connection; + +// AuthenticatorBase is the base class for authenticator. It provides functions +// to perform individual authentication methods, which can be used to construct +// compound authentication flow. +class AuthenticatorBase { + public: + AuthenticatorBase(FilterContext* filter_context); + virtual ~AuthenticatorBase(); + + // Perform authentication. + virtual bool run(istio::authn::Payload*) PURE; + + // 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( + const istio::authentication::v1alpha1::MutualTls& params, + istio::authn::Payload* payload) const; + + // 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(const istio::authentication::v1alpha1::Jwt& params, + istio::authn::Payload* payload); + + // Mutable accessor to filter context. + FilterContext* filter_context() { return &filter_context_; } + + private: + // Pointer to filter state. Do not own. + FilterContext& filter_context_; + + bool validateTrustDomain(const Connection* connection) const; +}; + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif diff --git a/extensions/authn/authenticator_base_test.cc b/extensions/authn/authenticator_base_test.cc new file mode 100644 index 00000000000..89ee318680f --- /dev/null +++ b/extensions/authn/authenticator_base_test.cc @@ -0,0 +1,444 @@ +/* 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/authenticator_base.h" + +#include "common/common/base64.h" +#include "common/protobuf/protobuf.h" +#include "envoy/config/core/v3/base.pb.h" +#include "envoy/config/filter/http/authn/v2alpha1/config.pb.h" +#include "extensions/authn/test_utils.h" +#include "gmock/gmock.h" +#include "src/envoy/utils/filter_names.h" +#include "test/mocks/network/mocks.h" +#include "test/mocks/ssl/mocks.h" + +using google::protobuf::util::MessageDifferencer; +using istio::authn::Payload; +using istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig; +using testing::NiceMock; +using testing::Return; +using testing::StrictMock; + +namespace iaapi = istio::authentication::v1alpha1; + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { +namespace { + +const std::string 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" + } + )"; + +const std::string kExchangedTokenHeaderName = "ingress-authorization"; + +const std::string kExchangedTokenPayload = + R"( + { + "iss": "token-service", + "sub": "subject", + "aud": ["aud1", "aud2"], + "original_claims": { + "iss": "https://accounts.example.com", + "sub": "example-subject", + "email": "user@example.com" + } + } + )"; + +const std::string kExchangedTokenPayloadNoOriginalClaims = + R"( + { + "iss": "token-service", + "sub": "subject", + "aud": ["aud1", "aud2"] + } + )"; + +class MockAuthenticatorBase : public AuthenticatorBase { + public: + MockAuthenticatorBase(FilterContext* filter_context) + : AuthenticatorBase(filter_context) {} + MOCK_METHOD1(run, bool(Payload*)); +}; + +class ValidateX509Test : public testing::TestWithParam { + public: + virtual ~ValidateX509Test() {} + + NiceMock connection_{}; + Envoy::Http::TestRequestHeaderMapImpl header_{}; + FilterConfig filter_config_{}; + FilterContext filter_context_{ + envoy::config::core::v3::Metadata::default_instance(), header_, + &connection_, filter_config_}; + MockAuthenticatorBase authenticator_{&filter_context_}; + + void SetUp() override { + mtls_params_.set_mode(GetParam()); + payload_ = new Payload(); + } + + void TearDown() override { delete (payload_); } + + protected: + iaapi::MutualTls mtls_params_; + iaapi::Jwt jwt_; + Payload* payload_; + Payload default_payload_; +}; + +TEST_P(ValidateX509Test, PlaintextConnection) { + // Should return false except mode is PERMISSIVE (accept plaintext) + if (GetParam() == iaapi::MutualTls::PERMISSIVE) { + EXPECT_TRUE(authenticator_.validateX509(mtls_params_, payload_)); + } else { + EXPECT_FALSE(authenticator_.validateX509(mtls_params_, payload_)); + } + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_P(ValidateX509Test, SslConnectionWithNoPeerCert) { + auto ssl = std::make_shared>(); + ON_CALL(*ssl, peerCertificatePresented()).WillByDefault(Return(false)); + EXPECT_CALL(Const(connection_), ssl()).WillRepeatedly(Return(ssl)); + + // Should return false except mode is PERMISSIVE (accept plaintext). + if (GetParam() == iaapi::MutualTls::PERMISSIVE) { + EXPECT_TRUE(authenticator_.validateX509(mtls_params_, payload_)); + } else { + EXPECT_FALSE(authenticator_.validateX509(mtls_params_, payload_)); + } + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_P(ValidateX509Test, SslConnectionWithPeerCert) { + auto ssl = std::make_shared>(); + ON_CALL(*ssl, peerCertificatePresented()).WillByDefault(Return(true)); + ON_CALL(*ssl, uriSanPeerCertificate()) + .WillByDefault(Return(std::vector{"foo"})); + EXPECT_CALL(Const(connection_), ssl()).WillRepeatedly(Return(ssl)); + + // Should return false due to unable to extract trust domain from principal. + EXPECT_FALSE(authenticator_.validateX509(mtls_params_, payload_)); + // When client certificate is present on mTLS, authenticated attribute should + // be extracted. + EXPECT_EQ(payload_->x509().user(), "foo"); +} + +TEST_P(ValidateX509Test, SslConnectionWithCertsSkipTrustDomainValidation) { + // skip trust domain validation. + google::protobuf::util::JsonParseOptions options; + JsonStringToMessage("{ skip_validate_trust_domain: true }", &filter_config_, + options); + + auto ssl = std::make_shared>(); + ON_CALL(*ssl, peerCertificatePresented()).WillByDefault(Return(true)); + ON_CALL(*ssl, uriSanPeerCertificate()) + .WillByDefault(Return(std::vector{"foo"})); + EXPECT_CALL(Const(connection_), ssl()).WillRepeatedly(Return(ssl)); + + // Should return true due to trust domain validation skipped. + EXPECT_TRUE(authenticator_.validateX509(mtls_params_, payload_)); + EXPECT_EQ(payload_->x509().user(), "foo"); +} + +TEST_P(ValidateX509Test, SslConnectionWithSpiffeCertsSameTrustDomain) { + auto ssl = std::make_shared>(); + ON_CALL(*ssl, peerCertificatePresented()).WillByDefault(Return(true)); + ON_CALL(*ssl, uriSanPeerCertificate()) + .WillByDefault(Return(std::vector{"spiffe://td/foo"})); + ON_CALL(*ssl, uriSanLocalCertificate()) + .WillByDefault(Return(std::vector{"spiffe://td/bar"})); + EXPECT_CALL(Const(connection_), ssl()).WillRepeatedly(Return(ssl)); + + EXPECT_TRUE(authenticator_.validateX509(mtls_params_, payload_)); + // When client certificate is present on mTLS, authenticated attribute should + // be extracted. + EXPECT_EQ(payload_->x509().user(), "td/foo"); +} + +TEST_P(ValidateX509Test, SslConnectionWithSpiffeCertsDifferentTrustDomain) { + auto ssl = std::make_shared>(); + ON_CALL(*ssl, peerCertificatePresented()).WillByDefault(Return(true)); + ON_CALL(*ssl, uriSanPeerCertificate()) + .WillByDefault(Return(std::vector{"spiffe://td-1/foo"})); + ON_CALL(*ssl, uriSanLocalCertificate()) + .WillByDefault(Return(std::vector{"spiffe://td-2/bar"})); + EXPECT_CALL(Const(connection_), ssl()).WillRepeatedly(Return(ssl)); + + // Should return false due to trust domain validation failed. + EXPECT_FALSE(authenticator_.validateX509(mtls_params_, payload_)); + // When client certificate is present on mTLS, authenticated attribute should + // be extracted. + EXPECT_EQ(payload_->x509().user(), "td-1/foo"); +} + +TEST_P(ValidateX509Test, SslConnectionWithPeerMalformedSpiffeCert) { + // skip trust domain validation. + google::protobuf::util::JsonParseOptions options; + JsonStringToMessage("{ skip_validate_trust_domain: true }", &filter_config_, + options); + + auto ssl = std::make_shared>(); + ON_CALL(*ssl, peerCertificatePresented()).WillByDefault(Return(true)); + ON_CALL(*ssl, uriSanPeerCertificate()) + .WillByDefault(Return(std::vector{"spiffe:foo"})); + ON_CALL(*ssl, uriSanLocalCertificate()) + .WillByDefault(Return(std::vector{"spiffe://td-2/bar"})); + EXPECT_CALL(Const(connection_), ssl()).WillRepeatedly(Return(ssl)); + + EXPECT_TRUE(authenticator_.validateX509(mtls_params_, payload_)); + // When client certificate is present on mTLS and the spiffe subject format is + // wrong + // ("spiffe:foo" instead of "spiffe://foo"), the user attribute should be + // extracted. + EXPECT_EQ(payload_->x509().user(), "spiffe:foo"); +} + +INSTANTIATE_TEST_SUITE_P(ValidateX509Tests, ValidateX509Test, + testing::Values(iaapi::MutualTls::STRICT, + iaapi::MutualTls::PERMISSIVE)); + +class ValidateJwtTest : public testing::Test { + public: + virtual ~ValidateJwtTest() {} + + // StrictMock request_info_{}; + envoy::config::core::v3::Metadata dynamic_metadata_; + NiceMock connection_{}; + Envoy::Http::TestRequestHeaderMapImpl header_{}; + FilterConfig filter_config_{}; + FilterContext filter_context_{dynamic_metadata_, header_, &connection_, + filter_config_}; + MockAuthenticatorBase authenticator_{&filter_context_}; + + void SetUp() override { payload_ = new Payload(); } + + void TearDown() override { delete (payload_); } + + protected: + iaapi::MutualTls mtls_params_; + iaapi::Jwt jwt_; + Payload* payload_; + Payload default_payload_; +}; + +TEST_F(ValidateJwtTest, NoIstioAuthnConfig) { + jwt_.set_issuer("issuer@foo.com"); + // authenticator_ has empty Istio authn config + // When there is empty Istio authn config, validateJwt() should return + // nullptr and failure. + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_F(ValidateJwtTest, NoIssuer) { + // no issuer in jwt + google::protobuf::util::JsonParseOptions options; + JsonStringToMessage( + R"({ + "jwt_output_payload_locations": + { + "issuer@foo.com": "sec-istio-auth-userinfo" + } + } + )", + &filter_config_, options); + + // When there is no issuer in the JWT config, validateJwt() should return + // nullptr and failure. + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_F(ValidateJwtTest, OutputPayloadLocationNotDefine) { + jwt_.set_issuer("issuer@foo.com"); + google::protobuf::util::JsonParseOptions options; + JsonStringToMessage( + R"({ + "jwt_output_payload_locations": + { + } + } + )", + &filter_config_, options); + + // authenticator has empty jwt_output_payload_locations in Istio authn config + // When there is no matching jwt_output_payload_locations for the issuer in + // the Istio authn config, validateJwt() should return nullptr and failure. + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_F(ValidateJwtTest, NoJwtPayloadOutput) { + jwt_.set_issuer("issuer@foo.com"); + + // When there is no JWT in request info dynamic metadata, validateJwt() should + // return nullptr and failure. + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_F(ValidateJwtTest, HasJwtPayloadOutputButNoDataForKey) { + jwt_.set_issuer("issuer@foo.com"); + + (*dynamic_metadata_ + .mutable_filter_metadata())[Envoy::Utils::IstioFilterName::kJwt] + .MergeFrom(Envoy::MessageUtil::keyValueStruct("foo", "bar")); + + // When there is no JWT payload for given issuer in request info dynamic + // metadata, validateJwt() should return nullptr and failure. + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(*payload_, default_payload_)); +} + +TEST_F(ValidateJwtTest, JwtPayloadAvailableWithBadData) { + jwt_.set_issuer("issuer@foo.com"); + (*dynamic_metadata_ + .mutable_filter_metadata())[Envoy::Utils::IstioFilterName::kJwt] + .MergeFrom( + Envoy::MessageUtil::keyValueStruct("issuer@foo.com", "bad-data")); + // EXPECT_CALL(request_info_, dynamicMetadata()); + + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equivalent(*payload_, default_payload_)); +} + +TEST_F(ValidateJwtTest, JwtPayloadAvailable) { + jwt_.set_issuer("issuer@foo.com"); + (*dynamic_metadata_ + .mutable_filter_metadata())[Envoy::Utils::IstioFilterName::kJwt] + .MergeFrom(Envoy::MessageUtil::keyValueStruct( + "issuer@foo.com", kSecIstioAuthUserinfoHeaderValue)); + + Payload expected_payload; + JsonStringToMessage( + R"({ + "jwt": { + "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"], + }, + "raw_claims": "\n {\n \"iss\": \"issuer@foo.com\",\n \"sub\": \"sub@foo.com\",\n \"aud\": [\"aud1\", \"aud2\"],\n \"non-string-will-be-ignored\": 1512754205,\n \"some-other-string-claims\": \"some-claims-kept\"\n }\n ", + } + } + )", + &expected_payload, google::protobuf::util::JsonParseOptions{}); + + EXPECT_TRUE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(expected_payload, *payload_)); +} + +TEST_F(ValidateJwtTest, OriginalPayloadOfExchangedToken) { + jwt_.set_issuer("token-service"); + jwt_.add_jwt_headers(kExchangedTokenHeaderName); + + (*dynamic_metadata_ + .mutable_filter_metadata())[Envoy::Utils::IstioFilterName::kJwt] + .MergeFrom(Envoy::MessageUtil::keyValueStruct("token-service", + kExchangedTokenPayload)); + + Payload expected_payload; + JsonStringToMessage( + R"({ + "jwt": { + "user": "https://accounts.example.com/example-subject", + "claims": { + "iss": ["https://accounts.example.com"], + "sub": ["example-subject"], + "email": ["user@example.com"] + }, + "raw_claims": "{\"email\":\"user@example.com\",\"iss\":\"https://accounts.example.com\",\"sub\":\"example-subject\"}" + } + } + )", + &expected_payload, google::protobuf::util::JsonParseOptions{}); + + EXPECT_TRUE(authenticator_.validateJwt(jwt_, payload_)); + // On different platforms, the order of fields in raw_claims may be + // different. E.g., on MacOs, the raw_claims in the payload_ can be: + // raw_claims: + // "{\"email\":\"user@example.com\",\"sub\":\"example-subject\",\"iss\":\"https://accounts.example.com\"}" + // Therefore, raw_claims is skipped to avoid a flaky test. + MessageDifferencer diff; + const google::protobuf::FieldDescriptor* field = + expected_payload.jwt().GetDescriptor()->FindFieldByName("raw_claims"); + diff.IgnoreField(field); + EXPECT_TRUE(diff.Compare(expected_payload, *payload_)); +} + +TEST_F(ValidateJwtTest, OriginalPayloadOfExchangedTokenMissing) { + jwt_.set_issuer("token-service"); + jwt_.add_jwt_headers(kExchangedTokenHeaderName); + + (*dynamic_metadata_ + .mutable_filter_metadata())[Envoy::Utils::IstioFilterName::kJwt] + .MergeFrom(Envoy::MessageUtil::keyValueStruct( + "token-service", kExchangedTokenPayloadNoOriginalClaims)); + + // When no original_claims in an exchanged token, the token + // is treated as invalid. + EXPECT_FALSE(authenticator_.validateJwt(jwt_, payload_)); +} + +TEST_F(ValidateJwtTest, OriginalPayloadOfExchangedTokenNotInIntendedHeader) { + jwt_.set_issuer("token-service"); + + (*dynamic_metadata_ + .mutable_filter_metadata())[Envoy::Utils::IstioFilterName::kJwt] + .MergeFrom(Envoy::MessageUtil::keyValueStruct("token-service", + kExchangedTokenPayload)); + + Payload expected_payload; + JsonStringToMessage( + R"({ + "jwt": { + "user": "token-service/subject", + "audiences": ["aud1", "aud2"], + "claims": { + "iss": ["token-service"], + "sub": ["subject"], + "aud": ["aud1", "aud2"] + }, + "raw_claims":"\n {\n \"iss\": \"token-service\",\n \"sub\": \"subject\",\n \"aud\": [\"aud1\", \"aud2\"],\n \"original_claims\": {\n \"iss\": \"https://accounts.example.com\",\n \"sub\": \"example-subject\",\n \"email\": \"user@example.com\"\n }\n }\n " + } + } + )", + &expected_payload, google::protobuf::util::JsonParseOptions{}); + + // When an exchanged token is not in the intended header, the token + // is treated as a normal token with its claims extracted. + EXPECT_TRUE(authenticator_.validateJwt(jwt_, payload_)); + EXPECT_TRUE(MessageDifferencer::Equals(expected_payload, *payload_)); +} + +} // namespace +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm diff --git a/extensions/authn/authn_utils.cc b/extensions/authn/authn_utils.cc new file mode 100644 index 00000000000..29228cb0c62 --- /dev/null +++ b/extensions/authn/authn_utils.cc @@ -0,0 +1,212 @@ +/* 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" +// #include "src/envoy/http/jwt_auth/jwt.h" + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +using proxy_wasm::null_plugin::logDebug; +using proxy_wasm::null_plugin::logError; +using proxy_wasm::null_plugin::logTrace; +using proxy_wasm::null_plugin::logWarn; + +#endif // NULL_PLUGIN + +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(); + logDebug(absl::StrCat(__FUNCTION__, ": json object is ", json_obj.dump())); + + *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) { + logDebug(absl::StrCat(__FUNCTION__, ": original_payload in exchanged token is of invalid format.")); + return false; + } + *original_payload = original_payload_obj.value().dump(); + + return true; +} + +bool AuthnUtils::MatchString(absl::string_view str, + const iaapi::StringMatch& match) { + switch (match.match_type_case()) { + case iaapi::StringMatch::kExact: { + return match.exact() == str; + } + case iaapi::StringMatch::kPrefix: { + return absl::StartsWith(str, match.prefix()); + } + case iaapi::StringMatch::kSuffix: { + return absl::EndsWith(str, match.suffix()); + } + case iaapi::StringMatch::kRegex: { + return std::regex_match(std::string(str), std::regex(match.regex())); + } + default: + return false; + } +} + +static bool matchRule(absl::string_view path, + const iaapi::Jwt_TriggerRule& rule) { + for (const auto& excluded : rule.excluded_paths()) { + if (AuthnUtils::MatchString(path, excluded)) { + // The rule is not matched if any of excluded_paths matched. + return false; + } + } + + if (rule.included_paths_size() > 0) { + for (const auto& included : rule.included_paths()) { + if (AuthnUtils::MatchString(path, included)) { + // The rule is matched if any of included_paths matched. + return true; + } + } + + // The rule is not matched if included_paths is not empty and none of them + // matched. + return false; + } + + // The rule is matched if none of excluded_paths matched and included_paths is + // empty. + return true; +} + +bool AuthnUtils::ShouldValidateJwtPerPath(absl::string_view path, + const iaapi::Jwt& jwt) { + // If the path is empty which shouldn't happen for a HTTP request or if + // there are no trigger rules at all, then simply return true as if there're + // no per-path jwt support. + if (path == "" || jwt.trigger_rules_size() == 0) { + return true; + } + for (const auto& rule : jwt.trigger_rules()) { + if (matchRule(path, rule)) { + return true; + } + } + return false; +} + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif diff --git a/extensions/authn/authn_utils.h b/extensions/authn/authn_utils.h new file mode 100644 index 00000000000..cf14901e610 --- /dev/null +++ b/extensions/authn/authn_utils.h @@ -0,0 +1,68 @@ +/* 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 "authentication/v1alpha1/policy.pb.h" +#include "src/istio/authn/context.pb.h" + +namespace iaapi = istio::authentication::v1alpha1; + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +// 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); + + // Returns true if str is matched to match. + static bool MatchString(absl::string_view str, + const iaapi::StringMatch& match); + + // Returns true if the jwt should be validated. It will check if the request + // path is matched to the trigger rule in the jwt. + static bool ShouldValidateJwtPerPath(absl::string_view path, + const iaapi::Jwt& jwt); +}; + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif diff --git a/extensions/authn/authn_utils_test.cc b/extensions/authn/authn_utils_test.cc new file mode 100644 index 00000000000..ae4e4755b45 --- /dev/null +++ b/extensions/authn/authn_utils_test.cc @@ -0,0 +1,351 @@ +/* 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 proxy_wasm { +namespace null_plugin { +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, MatchString) { + iaapi::StringMatch match; + EXPECT_FALSE(AuthnUtils::MatchString(nullptr, match)); + EXPECT_FALSE(AuthnUtils::MatchString("", match)); + + match.set_exact("exact"); + EXPECT_TRUE(AuthnUtils::MatchString("exact", match)); + EXPECT_FALSE(AuthnUtils::MatchString("exac", match)); + EXPECT_FALSE(AuthnUtils::MatchString("exacy", match)); + + match.set_prefix("prefix"); + EXPECT_TRUE(AuthnUtils::MatchString("prefix-1", match)); + EXPECT_TRUE(AuthnUtils::MatchString("prefix", match)); + EXPECT_FALSE(AuthnUtils::MatchString("prefi", match)); + EXPECT_FALSE(AuthnUtils::MatchString("prefiy", match)); + + match.set_suffix("suffix"); + EXPECT_TRUE(AuthnUtils::MatchString("1-suffix", match)); + EXPECT_TRUE(AuthnUtils::MatchString("suffix", match)); + EXPECT_FALSE(AuthnUtils::MatchString("suffi", match)); + EXPECT_FALSE(AuthnUtils::MatchString("suffiy", match)); + + match.set_regex(".+abc.+"); + EXPECT_TRUE(AuthnUtils::MatchString("1-abc-1", match)); + EXPECT_FALSE(AuthnUtils::MatchString("1-abc", match)); + EXPECT_FALSE(AuthnUtils::MatchString("abc-1", match)); + EXPECT_FALSE(AuthnUtils::MatchString("1-ac-1", match)); +} + +TEST(AuthnUtilsTest, ShouldValidateJwtPerPathExcluded) { + iaapi::Jwt jwt; + + // Create a rule that triggers on everything except /good-x and /allow-x. + auto* rule = jwt.add_trigger_rules(); + rule->add_excluded_paths()->set_exact("/good-x"); + rule->add_excluded_paths()->set_exact("/allow-x"); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/good-x", jwt)); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/allow-x", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/good-1", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/allow-1", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/other", jwt)); + + // Change the rule to only triggers on prefix /good and /allow. + rule->add_included_paths()->set_prefix("/good"); + rule->add_included_paths()->set_prefix("/allow"); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/good-x", jwt)); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/allow-x", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/good-1", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/allow-1", jwt)); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/other", jwt)); +} + +TEST(AuthnUtilsTest, ShouldValidateJwtPerPathIncluded) { + iaapi::Jwt jwt; + + // Create a rule that triggers on everything with prefix /good and /allow. + auto* rule = jwt.add_trigger_rules(); + rule->add_included_paths()->set_prefix("/good"); + rule->add_included_paths()->set_prefix("/allow"); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/good-x", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/allow-x", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/good-2", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/allow-1", jwt)); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/other", jwt)); + + // Change the rule to also exclude /allow-x and /good-x. + rule->add_excluded_paths()->set_exact("/good-x"); + rule->add_excluded_paths()->set_exact("/allow-x"); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/good-x", jwt)); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/allow-x", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/good-2", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/allow-1", jwt)); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/other", jwt)); +} + +TEST(AuthnUtilsTest, ShouldValidateJwtPerPathDefault) { + iaapi::Jwt jwt; + + // Always trigger when path is unavailable. + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("", jwt)); + + // Always trigger when there is no rules in jwt. + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/test", jwt)); + + // Add a rule that triggers on everything except /hello. + jwt.add_trigger_rules()->add_excluded_paths()->set_exact("/hello"); + EXPECT_FALSE(AuthnUtils::ShouldValidateJwtPerPath("/hello", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/other", jwt)); + + // Add another rule that triggers on path /hello. + jwt.add_trigger_rules()->add_included_paths()->set_exact("/hello"); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/hello", jwt)); + EXPECT_TRUE(AuthnUtils::ShouldValidateJwtPerPath("/other", jwt)); +} + +} // namespace +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm diff --git a/extensions/authn/filter_context.cc b/extensions/authn/filter_context.cc new file mode 100644 index 00000000000..588a6b6e0d2 --- /dev/null +++ b/extensions/authn/filter_context.cc @@ -0,0 +1,166 @@ +/* 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 iaapi = istio::authentication::v1alpha1; + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +using proxy_wasm::null_plugin::logDebug; +using proxy_wasm::null_plugin::logError; +using proxy_wasm::null_plugin::logTrace; +using proxy_wasm::null_plugin::logWarn; + +#endif // NULL_PLUGIN + +using Envoy::Extensions::HttpFilters::HttpFilterNames; +using Envoy::Utils::IstioFilterName; +using google::protobuf::util::MessageToJsonString; + +void FilterContext::setPeerResult(const Payload* payload) { + if (payload != nullptr) { + switch (payload->payload_case()) { + case Payload::kX509: + logDebug( + absl::StrCat("Set peer from X509: {}", payload->x509().user())); + result_.set_peer_user(payload->x509().user()); + break; + case Payload::kJwt: + logDebug(absl::StrCat("Set peer from JWT: {}", payload->jwt().user())); + result_.set_peer_user(payload->jwt().user()); + break; + default: + logDebug("Payload has not peer authentication data"); + break; + } + } +} +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::setPrincipal(const iaapi::PrincipalBinding& binding) { + switch (binding) { + case iaapi::PrincipalBinding::USE_PEER: + logDebug( + absl::StrCat("Set principal from peer: {}", result_.peer_user())); + result_.set_principal(result_.peer_user()); + return; + case iaapi::PrincipalBinding::USE_ORIGIN: + logDebug(absl::StrCat("Set principal from origin: {}", + result_.origin().user())); + result_.set_principal(result_.origin().user()); + return; + default: + // Should never come here. + logError(absl::StrCat("Invalid binding value ", binding)); + return; + } +} + +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()) { + logDebug(absl::StrCat("No dynamic_metadata found for filter {}", + HttpFilterNames::get().JwtAuthn)); + 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()) { + logDebug(absl::StrCat("No dynamic_metadata found for filter ", + IstioFilterName::kJwt)); + 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; +} + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif \ No newline at end of file diff --git a/extensions/authn/filter_context.h b/extensions/authn/filter_context.h new file mode 100644 index 00000000000..65f7a571462 --- /dev/null +++ b/extensions/authn/filter_context.h @@ -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. + */ + +#pragma once + +#include "authentication/v1alpha1/policy.pb.h" +#include "envoy/config/core/v3/base.pb.h" +#include "envoy/config/filter/http/authn/v2alpha1/config.pb.h" +#include "envoy/network/connection.h" +#include "extensions/filters/http/well_known_names.h" +#include "src/istio/authn/context.pb.h" + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +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 Connection* connection, + const istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig& + filter_config) + : dynamic_metadata_(dynamic_metadata), + header_map_(header_map), + connection_(connection), + filter_config_(filter_config) {} + virtual ~FilterContext() {} + + // Sets peer result based on authenticated payload. Input payload can be null, + // which basically changes nothing. + void setPeerResult(const istio::authn::Payload* payload); + + // Sets origin result based on authenticated payload. Input payload can be + // null, which basically changes nothing. + void setOriginResult(const istio::authn::Payload* payload); + + // Sets principal based on binding rule, and the existing peer and origin + // result. + void setPrincipal( + const istio::authentication::v1alpha1::PrincipalBinding& binding); + + // Returns the authentication result. + const istio::authn::Result& authenticationResult() { return result_; } + + // Accessor to connection + const Connection* connection() { return connection_; } + // Accessor to the filter config + const istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig& + filter_config() const { + return filter_config_; + } + + // 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; + + const RequestHeaderMap& headerMap() const { return header_map_; } + + 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_; + + // Pointer to network connection of the request. + const Connection* connection_; + + // Holds authentication attribute outputs. + istio::authn::Result result_; + + // Store the Istio authn filter config. + const istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig& + filter_config_; +}; + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif \ No newline at end of file diff --git a/extensions/authn/filter_context_test.cc b/extensions/authn/filter_context_test.cc new file mode 100644 index 00000000000..6420f03988c --- /dev/null +++ b/extensions/authn/filter_context_test.cc @@ -0,0 +1,133 @@ +/* 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 iaapi = istio::authentication::v1alpha1; + +namespace proxy_wasm { +namespace null_plugin { +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:: + v2alpha1::FilterConfig::default_instance()}; + + Payload x509_payload_{TestUtilities::CreateX509Payload("foo")}; + Payload jwt_payload_{TestUtilities::CreateJwtPayload("bar", "istio.io")}; +}; + +TEST_F(FilterContextTest, SetPeerResult) { + filter_context_.setPeerResult(&x509_payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + TestUtilities::AuthNResultFromString("peer_user: \"foo\""), + filter_context_.authenticationResult())); +} + +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())); +} + +TEST_F(FilterContextTest, SetBoth) { + filter_context_.setPeerResult(&x509_payload_); + filter_context_.setOriginResult(&jwt_payload_); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(R"( + peer_user: "foo" + origin { + user: "bar" + presenter: "istio.io" + } + )"), + filter_context_.authenticationResult())); +} + +TEST_F(FilterContextTest, UseOrigin) { + filter_context_.setPeerResult(&x509_payload_); + filter_context_.setOriginResult(&jwt_payload_); + filter_context_.setPrincipal(iaapi::PrincipalBinding::USE_ORIGIN); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(R"( + principal: "bar" + peer_user: "foo" + origin { + user: "bar" + presenter: "istio.io" + } + )"), + filter_context_.authenticationResult())); +} + +TEST_F(FilterContextTest, UseOriginOnEmptyOrigin) { + filter_context_.setPeerResult(&x509_payload_); + filter_context_.setPrincipal(iaapi::PrincipalBinding::USE_ORIGIN); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(R"( + peer_user: "foo" + )"), + filter_context_.authenticationResult())); +} + +TEST_F(FilterContextTest, PrincipalUsePeer) { + filter_context_.setPeerResult(&x509_payload_); + filter_context_.setPrincipal(iaapi::PrincipalBinding::USE_PEER); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(R"( + principal: "foo" + peer_user: "foo" + )"), + filter_context_.authenticationResult())); +} + +TEST_F(FilterContextTest, PrincipalUsePeerOnEmptyPeer) { + filter_context_.setOriginResult(&jwt_payload_); + filter_context_.setPrincipal(iaapi::PrincipalBinding::USE_PEER); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(R"( + origin { + user: "bar" + presenter: "istio.io" + } + )"), + filter_context_.authenticationResult())); +} + +} // namespace +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm diff --git a/extensions/authn/http_filter_integration_test.cc b/extensions/authn/http_filter_integration_test.cc new file mode 100644 index 00000000000..d6d39a98678 --- /dev/null +++ b/extensions/authn/http_filter_integration_test.cc @@ -0,0 +1,199 @@ +/* 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 "common/common/base64.h" +#include "common/common/utility.h" +#include "extensions/filters/http/well_known_names.h" +#include "fmt/printf.h" +#include "src/envoy/utils/filter_names.h" +#include "src/istio/authn/context.pb.h" +#include "test/integration/http_protocol_integration.h" + +using google::protobuf::util::MessageDifferencer; +using istio::authn::Payload; +using istio::authn::Result; + +namespace Envoy { +namespace { + +static const Envoy::Http::LowerCaseString kSecIstioAuthnPayloadHeaderKey( + "sec-istio-authn-payload"); + +// Default request for testing. +Http::TestRequestHeaderMapImpl SimpleRequestHeaders() { + return Http::TestRequestHeaderMapImpl{{":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-forwarded-for", "10.0.0.1"}}; +} + +// Keep the same as issuer in the policy below. +static const char kJwtIssuer[] = "some@issuer"; + +static const char kAuthnFilterWithJwt[] = R"( + name: istio_authn + config: + policy: + origins: + - jwt: + issuer: some@issuer + jwks_uri: http://localhost:8081/)"; + +// Payload data to inject. Note the iss claim intentionally set different from +// kJwtIssuer. +static const char kMockJwtPayload[] = + "{\"iss\":\"https://example.com\"," + "\"sub\":\"test@example.com\",\"exp\":2001001001," + "\"aud\":\"example_service\"}"; +// Returns a simple header-to-metadata filter config that can be used to inject +// data into request info dynamic metadata for testing. +std::string MakeHeaderToMetadataConfig() { + return fmt::sprintf( + R"( + name: %s + config: + request_rules: + - header: x-mock-metadata-injection + on_header_missing: + metadata_namespace: %s + key: %s + value: "%s" + type: STRING)", + Extensions::HttpFilters::HttpFilterNames::get().HeaderToMetadata, + Utils::IstioFilterName::kJwt, kJwtIssuer, + StringUtil::escape(kMockJwtPayload)); +} + +typedef HttpProtocolIntegrationTest AuthenticationFilterIntegrationTest; + +INSTANTIATE_TEST_SUITE_P( + Protocols, AuthenticationFilterIntegrationTest, + testing::ValuesIn(HttpProtocolIntegrationTest::getProtocolTestParams()), + HttpProtocolIntegrationTest::protocolTestParamsToString); + +TEST_P(AuthenticationFilterIntegrationTest, EmptyPolicy) { + config_helper_.addFilter("name: istio_authn"); + initialize(); + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto response = codec_client_->makeHeaderOnlyRequest(SimpleRequestHeaders()); + // Wait for request to upstream (backend) + waitForNextUpstreamRequest(); + + // Send backend response. + upstream_request_->encodeHeaders( + Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().Status()->value().getStringView()); +} + +TEST_P(AuthenticationFilterIntegrationTest, SourceMTlsFail) { + config_helper_.addFilter(R"( + name: istio_authn + config: + policy: + peers: + - mtls: {})"); + initialize(); + + // AuthN filter use MTls, but request doesn't have certificate, request + // would be rejected. + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto response = codec_client_->makeHeaderOnlyRequest(SimpleRequestHeaders()); + + // Request is rejected, there will be no upstream request (thus no + // waitForNextUpstreamRequest). + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("401", response->headers().Status()->value().getStringView()); +} + +// TODO (diemtvu/lei-tang): add test for MTls success. + +TEST_P(AuthenticationFilterIntegrationTest, OriginJwtRequiredHeaderNoJwtFail) { + config_helper_.addFilter(kAuthnFilterWithJwt); + initialize(); + + // The AuthN filter requires JWT, but request doesn't have JWT, request + // would be rejected. + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto response = codec_client_->makeHeaderOnlyRequest(SimpleRequestHeaders()); + + // Request is rejected, there will be no upstream request (thus no + // waitForNextUpstreamRequest). + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("401", response->headers().Status()->value().getStringView()); +} + +TEST_P(AuthenticationFilterIntegrationTest, CheckValidJwtPassAuthentication) { + config_helper_.addFilter(kAuthnFilterWithJwt); + config_helper_.addFilter(MakeHeaderToMetadataConfig()); + initialize(); + + // The AuthN filter requires JWT. The http request contains validated JWT and + // the authentication should succeed. + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto response = codec_client_->makeHeaderOnlyRequest(SimpleRequestHeaders()); + + // Wait for request to upstream (backend) + waitForNextUpstreamRequest(); + // Send backend response. + upstream_request_->encodeHeaders( + Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().Status()->value().getStringView()); +} + +TEST_P(AuthenticationFilterIntegrationTest, CORSPreflight) { + config_helper_.addFilter(kAuthnFilterWithJwt); + initialize(); + + // The AuthN filter requires JWT but should bypass CORS preflight request even + // it doesn't have JWT token. + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto headers = Http::TestRequestHeaderMapImpl{ + {":method", "OPTIONS"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-forwarded-for", "10.0.0.1"}, + {"access-control-request-method", "GET"}, + {"origin", "example.com"}, + }; + auto response = codec_client_->makeHeaderOnlyRequest(headers); + + // Wait for request to upstream (backend) + waitForNextUpstreamRequest(); + // Send backend response. + upstream_request_->encodeHeaders( + Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().Status()->value().getStringView()); +} + +} // namespace +} // namespace Envoy diff --git a/extensions/authn/http_filter_test.cc b/extensions/authn/http_filter_test.cc new file mode 100644 index 00000000000..241dfb5d3e4 --- /dev/null +++ b/extensions/authn/http_filter_test.cc @@ -0,0 +1,289 @@ +/* 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 "src/envoy/http/authn/http_filter.h" + +#include "common/common/base64.h" +#include "common/http/header_map_impl.h" +#include "common/stream_info/stream_info_impl.h" +#include "envoy/config/filter/http/authn/v2alpha1/config.pb.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "src/envoy/http/authn/authenticator_base.h" +#include "src/envoy/http/authn/test_utils.h" +#include "src/envoy/utils/authn.h" +#include "test/mocks/http/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +using Envoy::Http::Istio::AuthN::AuthenticatorBase; +using Envoy::Http::Istio::AuthN::FilterContext; +using istio::authn::Payload; +using istio::authn::Result; +using istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig; +using testing::_; +using testing::AtLeast; +using testing::Invoke; +using testing::NiceMock; +using testing::ReturnRef; +using testing::StrictMock; + +namespace iaapi = istio::authentication::v1alpha1; + +namespace Envoy { +namespace Http { +namespace Istio { +namespace AuthN { +namespace { + +const char ingoreBothPolicy[] = R"( + peer_is_optional: true + origin_is_optional: true +)"; + +// Create a fake authenticator for test. This authenticator do nothing except +// making the authentication fail. +std::unique_ptr createAlwaysFailAuthenticator( + FilterContext *filter_context) { + class _local : public AuthenticatorBase { + public: + _local(FilterContext *filter_context) : AuthenticatorBase(filter_context) {} + bool run(Payload *) override { return false; } + }; + return std::make_unique<_local>(filter_context); +} + +// Create a fake authenticator for test. This authenticator do nothing except +// making the authentication successful. +std::unique_ptr createAlwaysPassAuthenticator( + FilterContext *filter_context) { + class _local : public AuthenticatorBase { + public: + _local(FilterContext *filter_context) : AuthenticatorBase(filter_context) {} + bool run(Payload *) override { + // Set some data to verify authentication result later. + auto payload = TestUtilities::CreateX509Payload( + "cluster.local/sa/test_user/ns/test_ns/"); + filter_context()->setPeerResult(&payload); + return true; + } + }; + return std::make_unique<_local>(filter_context); +} + +class MockAuthenticationFilter : public AuthenticationFilter { + public: + // We'll use fake authenticator for test, so policy is not really needed. Use + // default config for simplicity. + MockAuthenticationFilter(const FilterConfig &filter_config) + : AuthenticationFilter(filter_config) {} + + ~MockAuthenticationFilter(){}; + + MOCK_METHOD1(createPeerAuthenticator, + std::unique_ptr(FilterContext *)); + MOCK_METHOD1(createOriginAuthenticator, + std::unique_ptr(FilterContext *)); +}; + +class AuthenticationFilterTest : public testing::Test { + public: + AuthenticationFilterTest() + : request_headers_{{":method", "GET"}, {":path", "/"}} {} + ~AuthenticationFilterTest() {} + + void SetUp() override { + filter_.setDecoderFilterCallbacks(decoder_callbacks_); + } + + protected: + FilterConfig filter_config_ = FilterConfig::default_instance(); + + Http::TestRequestHeaderMapImpl request_headers_; + StrictMock filter_{filter_config_}; + NiceMock decoder_callbacks_; +}; + +TEST_F(AuthenticationFilterTest, PeerFail) { + // Peer authentication fail, request should be rejected with 401. No origin + // authentiation needed. + EXPECT_CALL(filter_, createPeerAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysFailAuthenticator)); + Envoy::Event::SimulatedTimeSystem test_time; + StreamInfo::StreamInfoImpl stream_info(Http::Protocol::Http2, + test_time.timeSystem()); + EXPECT_CALL(decoder_callbacks_, streamInfo()) + .Times(AtLeast(1)) + .WillRepeatedly(ReturnRef(stream_info)); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(_, _)) + .Times(1) + .WillOnce(testing::Invoke([](Http::ResponseHeaderMap &headers, bool) { + EXPECT_EQ("401", headers.Status()->value().getStringView()); + })); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_.decodeHeaders(request_headers_, true)); + EXPECT_FALSE(Utils::Authentication::GetResultFromMetadata( + stream_info.dynamicMetadata())); +} + +TEST_F(AuthenticationFilterTest, PeerPassOriginFail) { + // Peer pass thus origin authentication must be called. Final result should + // fail as origin authn fails. + EXPECT_CALL(filter_, createPeerAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysPassAuthenticator)); + EXPECT_CALL(filter_, createOriginAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysFailAuthenticator)); + Envoy::Event::SimulatedTimeSystem test_time; + StreamInfo::StreamInfoImpl stream_info(Http::Protocol::Http2, + test_time.timeSystem()); + EXPECT_CALL(decoder_callbacks_, streamInfo()) + .Times(AtLeast(1)) + .WillRepeatedly(ReturnRef(stream_info)); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(_, _)) + .Times(1) + .WillOnce(testing::Invoke([](Http::ResponseHeaderMap &headers, bool) { + EXPECT_EQ("401", headers.Status()->value().getStringView()); + })); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_.decodeHeaders(request_headers_, true)); + EXPECT_FALSE(Utils::Authentication::GetResultFromMetadata( + stream_info.dynamicMetadata())); +} + +TEST_F(AuthenticationFilterTest, AllPass) { + EXPECT_CALL(filter_, createPeerAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysPassAuthenticator)); + EXPECT_CALL(filter_, createOriginAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysPassAuthenticator)); + Envoy::Event::SimulatedTimeSystem test_time; + StreamInfo::StreamInfoImpl stream_info(Http::Protocol::Http2, + test_time.timeSystem()); + EXPECT_CALL(decoder_callbacks_, streamInfo()) + .Times(AtLeast(1)) + .WillRepeatedly(ReturnRef(stream_info)); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + filter_.decodeHeaders(request_headers_, true)); + + EXPECT_EQ(1, stream_info.dynamicMetadata().filter_metadata_size()); + const auto *data = Utils::Authentication::GetResultFromMetadata( + stream_info.dynamicMetadata()); + ASSERT_TRUE(data); + + ProtobufWkt::Struct expected_data; + ASSERT_TRUE(Protobuf::TextFormat::ParseFromString(R"( + fields { + key: "source.namespace" + value { + string_value: "test_ns" + } + } + fields { + key: "source.principal" + value { + string_value: "cluster.local/sa/test_user/ns/test_ns/" + } + } + fields { + key: "source.user" + value { + string_value: "cluster.local/sa/test_user/ns/test_ns/" + } + })", + &expected_data)); + EXPECT_TRUE(TestUtility::protoEqual(expected_data, *data)); +} + +TEST_F(AuthenticationFilterTest, IgnoreBothFail) { + iaapi::Policy policy_; + ASSERT_TRUE( + Protobuf::TextFormat::ParseFromString(ingoreBothPolicy, &policy_)); + *filter_config_.mutable_policy() = policy_; + StrictMock filter(filter_config_); + filter.setDecoderFilterCallbacks(decoder_callbacks_); + + EXPECT_CALL(filter, createPeerAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysFailAuthenticator)); + EXPECT_CALL(filter, createOriginAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysFailAuthenticator)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + filter.decodeHeaders(request_headers_, true)); +} + +TEST_F(AuthenticationFilterTest, IgnoreBothPass) { + iaapi::Policy policy_; + ASSERT_TRUE( + Protobuf::TextFormat::ParseFromString(ingoreBothPolicy, &policy_)); + *filter_config_.mutable_policy() = policy_; + StrictMock filter(filter_config_); + filter.setDecoderFilterCallbacks(decoder_callbacks_); + + EXPECT_CALL(filter, createPeerAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysPassAuthenticator)); + EXPECT_CALL(filter, createOriginAuthenticator(_)) + .Times(1) + .WillOnce(Invoke(createAlwaysPassAuthenticator)); + Envoy::Event::SimulatedTimeSystem test_time; + StreamInfo::StreamInfoImpl stream_info(Http::Protocol::Http2, + test_time.timeSystem()); + EXPECT_CALL(decoder_callbacks_, streamInfo()) + .Times(AtLeast(1)) + .WillRepeatedly(ReturnRef(stream_info)); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, + filter.decodeHeaders(request_headers_, true)); + + EXPECT_EQ(1, stream_info.dynamicMetadata().filter_metadata_size()); + const auto *data = Utils::Authentication::GetResultFromMetadata( + stream_info.dynamicMetadata()); + ASSERT_TRUE(data); + + ProtobufWkt::Struct expected_data; + ASSERT_TRUE(Protobuf::TextFormat::ParseFromString(R"( + fields { + key: "source.namespace" + value { + string_value: "test_ns" + } + } + fields { + key: "source.principal" + value { + string_value: "cluster.local/sa/test_user/ns/test_ns/" + } + } + fields { + key: "source.user" + value { + string_value: "cluster.local/sa/test_user/ns/test_ns/" + } + })", + &expected_data)); + EXPECT_TRUE(TestUtility::protoEqual(expected_data, *data)); +} + +} // namespace +} // namespace AuthN +} // namespace Istio +} // namespace Http +} // namespace Envoy diff --git a/extensions/authn/origin_authenticator.cc b/extensions/authn/origin_authenticator.cc new file mode 100644 index 00000000000..676fbcb8a8d --- /dev/null +++ b/extensions/authn/origin_authenticator.cc @@ -0,0 +1,141 @@ +/* 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/origin_authenticator.h" + +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "authentication/v1alpha1/policy.pb.h" +#include "common/http/headers.h" +#include "extensions/authn/authn_utils.h" + +using istio::authn::Payload; + +namespace iaapi = istio::authentication::v1alpha1; + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +using proxy_wasm::null_plugin::logDebug; +using proxy_wasm::null_plugin::logError; +using proxy_wasm::null_plugin::logTrace; +using proxy_wasm::null_plugin::logWarn; + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +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(); +} + +OriginAuthenticator::OriginAuthenticator(FilterContext* filter_context, + const iaapi::Policy& policy) + : AuthenticatorBase(filter_context), policy_(policy) {} + +bool OriginAuthenticator::run(Payload* payload) { + if (policy_.origins_size() == 0 && + policy_.principal_binding() == iaapi::PrincipalBinding::USE_ORIGIN) { + // Validation should reject policy that have rule to USE_ORIGIN but + // does not provide any origin method so this code should + // never reach. However, it's ok to treat it as authentication + // fails. + logWarn(absl::StrCat( + "Principal is binded to origin, but no method specified in " + "policy {}", + policy_.DebugString())); + return false; + } + + 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. + logDebug("CORS preflight request allowed regardless of JWT policy"); + 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); + } + logTrace(absl::StrCat("Got request path {}", path)); + } else { + logError( + "Failed to get request path, JWT will always be used for " + "validation"); + } + + bool triggered = false; + bool triggered_success = false; + for (const auto& method : policy_.origins()) { + const auto& jwt = method.jwt(); + + if (AuthnUtils::ShouldValidateJwtPerPath(path, jwt)) { + logDebug(absl::StrCat("Validating request path {} for jwt {}", path, + jwt.DebugString())); + // set triggered to true if any of the jwt trigger rule matched. + triggered = true; + if (validateJwt(jwt, payload)) { + logDebug("JWT validation succeeded"); + triggered_success = true; + break; + } + } + } + + // returns true if no jwt was triggered, or triggered and success. + if (!triggered || triggered_success) { + filter_context()->setOriginResult(payload); + filter_context()->setPrincipal(policy_.principal_binding()); + logDebug("Origin authenticator succeeded"); + return true; + } + + logDebug("Origin authenticator failed"); + return false; +} + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif \ No newline at end of file diff --git a/extensions/authn/origin_authenticator.h b/extensions/authn/origin_authenticator.h new file mode 100644 index 00000000000..84ee300462c --- /dev/null +++ b/extensions/authn/origin_authenticator.h @@ -0,0 +1,54 @@ +/* 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 "authentication/v1alpha1/policy.pb.h" +#include "extensions/authn/authenticator_base.h" + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +// OriginAuthenticator performs origin authentication for given credential rule. +class OriginAuthenticator : public AuthenticatorBase { + public: + OriginAuthenticator(FilterContext* filter_context, + const istio::authentication::v1alpha1::Policy& policy); + + bool run(istio::authn::Payload*) override; + + private: + // Reference to the authentication policy that the authenticator should + // enforce. Typically, the actual object is owned by filter. + const istio::authentication::v1alpha1::Policy& policy_; +}; + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif \ No newline at end of file diff --git a/extensions/authn/origin_authenticator_test.cc b/extensions/authn/origin_authenticator_test.cc new file mode 100644 index 00000000000..ab32d1cfc45 --- /dev/null +++ b/extensions/authn/origin_authenticator_test.cc @@ -0,0 +1,522 @@ +/* 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/origin_authenticator.h" + +#include "authentication/v1alpha1/policy.pb.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 "test/mocks/http/mocks.h" +#include "test/test_common/utility.h" + +namespace iaapi = istio::authentication::v1alpha1; + +using istio::authn::Payload; +using istio::authn::Result; +using testing::_; +using testing::DoAll; +using testing::MockFunction; +using testing::NiceMock; +using testing::Return; +using testing::SetArgPointee; +using testing::StrictMock; + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { +namespace { + +const char kZeroOriginMethodPolicyBindPeer[] = R"( + principal_binding: USE_PEER +)"; + +const char kZeroOriginMethodPolicyBindOrigin[] = R"( + principal_binding: USE_ORIGIN +)"; + +const char kSingleOriginMethodPolicy[] = R"( + principal_binding: USE_ORIGIN + origins { + jwt { + issuer: "abc.xyz" + } + } +)"; + +const char kMultipleOriginMethodsPolicy[] = R"( + principal_binding: USE_ORIGIN + origins { + jwt { + issuer: "one" + } + } + origins { + jwt { + issuer: "two" + } + } + origins { + jwt { + issuer: "three" + } + } +)"; + +const char kPeerBinding[] = R"( + principal_binding: USE_PEER + origins { + jwt { + issuer: "abc.xyz" + } + } +)"; + +const char kSingleOriginMethodWithTriggerRulePolicy[] = R"( + principal_binding: USE_ORIGIN + origins { + jwt { + issuer: "abc.xyz" + trigger_rules: { + included_paths: { + exact: "/allow" + } + } + } + } +)"; + +const char kSingleOriginMethodWithExcludeTriggerRulePolicy[] = R"( + principal_binding: USE_ORIGIN + origins { + jwt { + issuer: "abc.xyz" + trigger_rules: { + excluded_paths: { + exact: "/login" + } + } + } + } +)"; + +const char kMultipleOriginMethodWithTriggerRulePolicy[] = R"( + principal_binding: USE_ORIGIN + origins { + jwt { + issuer: "one" + trigger_rules: { + excluded_paths: { + exact: "/bad" + } + } + } + } + origins { + jwt { + issuer: "two" + trigger_rules: { + included_paths: { + exact: "/two" + } + } + } + } + origins { + jwt { + issuer: "three" + trigger_rules: { + included_paths: { + exact: "/allow" + } + } + } + } +)"; + +class MockOriginAuthenticator : public OriginAuthenticator { + public: + MockOriginAuthenticator(FilterContext* filter_context, + const iaapi::Policy& policy) + : OriginAuthenticator(filter_context, policy) {} + + MOCK_CONST_METHOD2(validateX509, bool(const iaapi::MutualTls&, Payload*)); + MOCK_METHOD2(validateJwt, bool(const iaapi::Jwt&, Payload*)); +}; + +class OriginAuthenticatorTest : public testing::TestWithParam { + public: + OriginAuthenticatorTest() {} + virtual ~OriginAuthenticatorTest() {} + + void SetUp() override { + expected_result_when_pass_ = TestUtilities::AuthNResultFromString(R"( + principal: "foo" + origin { + user: "foo" + presenter: "istio.io" + } + )"); + set_peer_ = GetParam(); + if (set_peer_) { + auto peer_result = TestUtilities::CreateX509Payload("bar"); + filter_context_.setPeerResult(&peer_result); + expected_result_when_pass_.set_peer_user("bar"); + } + initial_result_ = filter_context_.authenticationResult(); + payload_ = new Payload(); + } + + void TearDown() override { delete (payload_); } + + void createAuthenticator() { + authenticator_.reset( + new StrictMock(&filter_context_, policy_)); + } + + protected: + std::unique_ptr> authenticator_; + // envoy::config::core::v3::Metadata metadata_; + Envoy::Http::TestRequestHeaderMapImpl header_{}; + FilterContext filter_context_{ + envoy::config::core::v3::Metadata::default_instance(), header_, nullptr, + istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig:: + default_instance()}; + iaapi::Policy policy_; + + Payload* payload_; + + // Mock response payload. + Payload jwt_payload_{TestUtilities::CreateJwtPayload("foo", "istio.io")}; + Payload jwt_extra_payload_{ + TestUtilities::CreateJwtPayload("bar", "istio.io")}; + + // Expected result (when authentication pass with mock payload above) + Result expected_result_when_pass_; + // Copy of authN result (from filter context) before running authentication. + // This should be the expected result if authn fail or do nothing. + Result initial_result_; + + // Indicates peer is set in the authN result before running. This is set from + // test GetParam() + bool set_peer_; + + void setPath(const std::string& path) { + header_.removePath(); + header_.addCopy(":path", path); + } + + void addHeader(const std::string& key, const std::string& value) { + header_.addCopy(key, value); + } +}; + +TEST_P(OriginAuthenticatorTest, Empty) { + createAuthenticator(); + authenticator_->run(payload_); + if (set_peer_) { + initial_result_.set_principal("bar"); + } + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +// It should fail if the binding is USE_ORIGIN but origin methods are empty. +TEST_P(OriginAuthenticatorTest, ZeroMethodFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kZeroOriginMethodPolicyBindOrigin, &policy_)); + createAuthenticator(); + EXPECT_FALSE(authenticator_->run(payload_)); +} + +// It should pass if the binding is USE_PEER and origin methods are empty. +TEST_P(OriginAuthenticatorTest, ZeroMethodPass) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kZeroOriginMethodPolicyBindPeer, &policy_)); + createAuthenticator(); + + Result expected_result = TestUtilities::AuthNResultFromString(R"( + origin { + user: "bar" + presenter: "istio.io" + } + )"); + if (set_peer_) { + expected_result.set_principal("bar"); + expected_result.set_peer_user("bar"); + } + + EXPECT_TRUE(authenticator_->run(&jwt_extra_payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, SingleMethodPass) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodPolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, SingleMethodFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodPolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(false))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, CORSPreflight) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodPolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)).Times(0); + + addHeader(":method", "OPTIONS"); + addHeader("origin", "example.com"); + addHeader("access-control-request-method", "GET"); + EXPECT_TRUE(authenticator_->run(payload_)); +} + +TEST_P(OriginAuthenticatorTest, TriggeredWithNullPath) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, SingleRuleTriggered) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + setPath("/allow"); + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, SingleRuleTriggeredWithComponents) { + const std::vector input_paths{"/allow?", + "/allow?a=b&c=d", + "/allow??", + "/allow??", + "/allow?#", + "/allow#?", + "/allow#a", + "/allow#$" + "/allow?a=b#c", + "/allow#a?b=c"}; + for (const auto& path : input_paths) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + setPath(path); + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); + } +} + +TEST_P(OriginAuthenticatorTest, SingleRuleNotTriggered) { + const std::vector input_paths{"/bad", "/allow$?", "/allow$#"}; + for (const auto& path : input_paths) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)).Times(0); + + setPath(path); + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); + } +} + +TEST_P(OriginAuthenticatorTest, SingleExcludeRuleTriggeredWithQueryParam) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kSingleOriginMethodWithExcludeTriggerRulePolicy, &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)).Times(0); + + setPath("/login?a=b&c=d"); + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, Multiple) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kMultipleOriginMethodsPolicy, &policy_)); + + createAuthenticator(); + + // First method fails, second success (thus third is ignored) + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(2) + .WillOnce(DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, MultipleFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kMultipleOriginMethodsPolicy, &policy_)); + + createAuthenticator(); + + // All fail. + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(3) + .WillRepeatedly( + DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, MultipleRuleTriggeredValidationSucceeded) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kMultipleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + // First method triggered but failed, second method not triggered, third + // method triggered and succeeded. + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(2) + .WillOnce(DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + setPath("/allow"); + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, MultipleRuleTriggeredValidationFailed) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kMultipleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + // Triggered on first and second method but all failed. + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(2) + .WillRepeatedly( + DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + + setPath("/two"); + EXPECT_FALSE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, MultipleRuleNotTriggered) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString( + kMultipleOriginMethodWithTriggerRulePolicy, &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateJwt(_, _)).Times(0); + + setPath("/bad"); + EXPECT_TRUE(authenticator_->run(payload_)); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, PeerBindingPass) { + ASSERT_TRUE( + Envoy::Protobuf::TextFormat::ParseFromString(kPeerBinding, &policy_)); + // Expected principal is from peer_user. + expected_result_when_pass_.set_principal(initial_result_.peer_user()); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + expected_result_when_pass_, filter_context_.authenticationResult())); +} + +TEST_P(OriginAuthenticatorTest, PeerBindingFail) { + ASSERT_TRUE( + Envoy::Protobuf::TextFormat::ParseFromString(kPeerBinding, &policy_)); + createAuthenticator(); + + // All fail. + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(false))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + initial_result_, filter_context_.authenticationResult())); +} + +INSTANTIATE_TEST_SUITE_P(OriginAuthenticatorTests, OriginAuthenticatorTest, + testing::Bool()); + +} // namespace +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm diff --git a/extensions/authn/peer_authenticator.cc b/extensions/authn/peer_authenticator.cc new file mode 100644 index 00000000000..f755e5a6520 --- /dev/null +++ b/extensions/authn/peer_authenticator.cc @@ -0,0 +1,87 @@ +/* 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 "absl/strings/str_cat.h" +#include "src/envoy/utils/utils.h" + +using istio::authn::Payload; + +namespace iaapi = istio::authentication::v1alpha1; + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +using proxy_wasm::null_plugin::logDebug; +using proxy_wasm::null_plugin::logError; +using proxy_wasm::null_plugin::logTrace; +using proxy_wasm::null_plugin::logWarn; + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +PeerAuthenticator::PeerAuthenticator(FilterContext* filter_context, + const iaapi::Policy& policy) + : AuthenticatorBase(filter_context), policy_(policy) {} + +bool PeerAuthenticator::run(Payload* payload) { + bool success = false; + if (policy_.peers_size() == 0) { + logDebug("No method defined. Skip source authentication."); + success = true; + return success; + } + for (const auto& method : policy_.peers()) { + switch (method.params_case()) { + case iaapi::PeerAuthenticationMethod::ParamsCase::kMtls: + success = validateX509(method.mtls(), payload); + break; + case iaapi::PeerAuthenticationMethod::ParamsCase::kJwt: + success = validateJwt(method.jwt(), payload); + break; + default: + logError(absl::StrCat("Unknown peer authentication param ", + method.DebugString())); + success = false; + break; + } + + if (success) { + break; + } + } + + if (success) { + filter_context()->setPeerResult(payload); + } + + return success; +} + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif \ 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..30d0df525cb --- /dev/null +++ b/extensions/authn/peer_authenticator.h @@ -0,0 +1,54 @@ +/* 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 "authentication/v1alpha1/policy.pb.h" +#include "extensions/authn/authenticator_base.h" + +// WASM_PROLOG +#ifndef NULL_PLUGIN + +#include "proxy_wasm_intrinsics.h" + +#else // NULL_PLUGIN + +#include "include/proxy-wasm/null_plugin.h" + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif // NULL_PLUGIN + +// PeerAuthenticator performs peer authentication for given policy. +class PeerAuthenticator : public AuthenticatorBase { + public: + PeerAuthenticator(FilterContext* filter_context, + const istio::authentication::v1alpha1::Policy& policy); + + bool run(istio::authn::Payload*) override; + + private: + // Reference to the authentication policy that the authenticator should + // enforce. Typically, the actual object is owned by filter. + const istio::authentication::v1alpha1::Policy& policy_; +}; + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm +#endif \ No newline at end of file diff --git a/extensions/authn/peer_authenticator_test.cc b/extensions/authn/peer_authenticator_test.cc new file mode 100644 index 00000000000..1bcc0b3db49 --- /dev/null +++ b/extensions/authn/peer_authenticator_test.cc @@ -0,0 +1,351 @@ +/* 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 "authentication/v1alpha1/policy.pb.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 "test/mocks/http/mocks.h" +#include "test/test_common/utility.h" + +namespace iaapi = istio::authentication::v1alpha1; + +using istio::authn::Payload; +using testing::_; +using testing::DoAll; +using testing::MockFunction; +using testing::NiceMock; +using testing::Return; +using testing::SetArgPointee; +using testing::StrictMock; + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { +namespace { + +class MockPeerAuthenticator : public PeerAuthenticator { + public: + MockPeerAuthenticator(FilterContext* filter_context, + const istio::authentication::v1alpha1::Policy& policy) + : PeerAuthenticator(filter_context, policy) {} + + MOCK_CONST_METHOD2(validateX509, bool(const iaapi::MutualTls&, Payload*)); + MOCK_METHOD2(validateJwt, bool(const iaapi::Jwt&, Payload*)); +}; + +class PeerAuthenticatorTest : public testing::Test { + public: + PeerAuthenticatorTest() {} + virtual ~PeerAuthenticatorTest() {} + + void createAuthenticator() { + authenticator_.reset( + new StrictMock(&filter_context_, policy_)); + } + + void SetUp() override { payload_ = new Payload(); } + + void TearDown() override { delete (payload_); } + + protected: + std::unique_ptr> authenticator_; + Envoy::Http::TestRequestHeaderMapImpl header_; + FilterContext filter_context_{ + envoy::config::core::v3::Metadata::default_instance(), header_, nullptr, + istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig:: + default_instance()}; + + iaapi::Policy policy_; + Payload* payload_; + + Payload x509_payload_{TestUtilities::CreateX509Payload("foo")}; + Payload jwt_payload_{TestUtilities::CreateJwtPayload("foo", "istio.io")}; + Payload jwt_extra_payload_{ + TestUtilities::CreateJwtPayload("bar", "istio.io")}; +}; + +TEST_F(PeerAuthenticatorTest, EmptyPolicy) { + createAuthenticator(); + authenticator_->run(payload_); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(""), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, MTlsOnlyPass) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls { + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(x509_payload_), Return(true))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + TestUtilities::AuthNResultFromString(R"(peer_user: "foo")"), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, TlsOnlyPass) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls { + allow_tls: true + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(x509_payload_), Return(true))); + + authenticator_->run(payload_); + // When client certificate is present on TLS, authenticated attribute + // should be extracted. + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + TestUtilities::AuthNResultFromString(R"(peer_user: "foo")"), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, MTlsOnlyFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls { + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(x509_payload_), Return(false))); + authenticator_->run(payload_); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(""), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, TlsOnlyFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls { + allow_tls: true + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(x509_payload_), Return(false))); + + authenticator_->run(payload_); + // When TLS authentication failse, the authenticated attribute should be + // empty. + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(""), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, JwtOnlyPass) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + jwt { + issuer: "abc.xyz" + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(x509_payload_), Return(true))); + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + TestUtilities::AuthNResultFromString(R"(peer_user: "foo")"), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, JwtOnlyFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + jwt { + issuer: "abc.xyz" + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(x509_payload_), Return(false))); + authenticator_->run(payload_); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(""), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, Multiple) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls {} + } + peers { + jwt { + issuer: "abc.xyz" + } + } + peers { + jwt { + issuer: "another" + } + } + )", + &policy_)); + + createAuthenticator(); + + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + + authenticator_->run(payload_); + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + TestUtilities::AuthNResultFromString(R"(peer_user: "foo")"), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, TlsFailAndJwtSucceed) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls { allow_tls: true } + } + peers { + jwt { + issuer: "abc.xyz" + } + } + peers { + jwt { + issuer: "another" + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_payload_), Return(true))); + authenticator_->run(payload_); + // validateX509 fail and validateJwt succeeds, + // result should be "foo", as expected as in jwt_payload. + EXPECT_TRUE(Envoy::TestUtility::protoEqual( + TestUtilities::AuthNResultFromString(R"(peer_user: "foo")"), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, MultipleAllFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls {} + } + peers { + jwt { + issuer: "abc.xyz" + } + } + peers { + jwt { + issuer: "another" + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(2) + .WillRepeatedly( + DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + authenticator_->run(payload_); + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(""), + filter_context_.authenticationResult())); +} + +TEST_F(PeerAuthenticatorTest, TlsFailJwtFail) { + ASSERT_TRUE(Envoy::Protobuf::TextFormat::ParseFromString(R"( + peers { + mtls { allow_tls: true } + } + peers { + jwt { + issuer: "abc.xyz" + } + } + peers { + jwt { + issuer: "another" + } + } + )", + &policy_)); + + createAuthenticator(); + EXPECT_CALL(*authenticator_, validateX509(_, _)) + .Times(1) + .WillOnce(DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + EXPECT_CALL(*authenticator_, validateJwt(_, _)) + .Times(2) + .WillRepeatedly( + DoAll(SetArgPointee<1>(jwt_extra_payload_), Return(false))); + authenticator_->run(payload_); + // validateX509 and validateJwt fail, result should be empty. + EXPECT_TRUE( + Envoy::TestUtility::protoEqual(TestUtilities::AuthNResultFromString(""), + filter_context_.authenticationResult())); +} + +} // namespace +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm diff --git a/extensions/authn/plugin.cc b/extensions/authn/plugin.cc new file mode 100644 index 00000000000..ea5443801e6 --- /dev/null +++ b/extensions/authn/plugin.cc @@ -0,0 +1,42 @@ +/* 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/plugin.h" + +#include "absl/strings/str_cat.h" +#include "authentication/v1alpha1/policy.pb.h" + +#ifdef NULL_PLUGIN + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif + +FilterHeadersStatus AuthnContext::onRequestHeaders(uint32_t, bool) { + return FilterHeadersStatus::Continue; +} + +const FilterConfig& AuthnContext::filterConfig() { + return dynamic_cast(this->root())->filterConfig(); +}; + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm + +#endif \ No newline at end of file diff --git a/extensions/authn/plugin.h b/extensions/authn/plugin.h new file mode 100644 index 00000000000..bf13107ff90 --- /dev/null +++ b/extensions/authn/plugin.h @@ -0,0 +1,86 @@ +/* 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 "envoy/config/filter/http/authn/v2alpha1/config.pb.h" +#include "extensions/authn/authenticator_base.h" + +#include "envoy/config/core/v3/base.pb.h" + +#ifndef NULL_PLUGIN +#include "proxy_wasm_intrinsics.h" +#else +#include "include/proxy-wasm/null_plugin.h" + +using proxy_wasm::null_plugin::logDebug; +using proxy_wasm::null_plugin::logError; + +namespace proxy_wasm { +namespace null_plugin { +namespace AuthN { + +#endif + +using istio::envoy::config::filter::http::authn::v2alpha1::FilterConfig; +using StringView = absl::string_view; + +// AuthnRootContext is the root context for all streams processed by the +// thread. It has the same lifetime as the worker thread and acts as target for +// interactions that outlives individual stream, e.g. timer, async calls. +class AuthnRootContext : public RootContext { + public: + AuthnRootContext(uint32_t id, absl::string_view root_id) + : RootContext(id, root_id) {} + ~AuthnRootContext() {} + + const FilterConfig& filterConfig() { return filter_config_; }; + + private: + FilterConfig filter_config_; +}; + +// Per-stream context. +class AuthnContext : public Context { + public: + explicit AuthnContext(uint32_t id, RootContext* root) : Context(id, root) {} + ~AuthnContext() = default; + + FilterHeadersStatus onRequestHeaders(uint32_t, bool) override; + + const FilterConfig& filterConfig(); + + private: + // std::unique_ptr createPeerAuthenticator( + // FilterContext* filter_context); + // TODO(shikugawa): origin authenticator implementation. + // std::unique_ptr createOriginAuthenticator( + // istio::AuthN::FilterContext* filter_context); +}; + +#ifdef NULL_PLUGIN +PROXY_WASM_NULL_PLUGIN_REGISTRY; +#endif + +static RegisterContextFactory register_AuthnWasm( + CONTEXT_FACTORY(AuthnContext), ROOT_FACTORY(AuthnRootContext)); + +#ifdef NULL_PLUGIN +} // namespace AuthN +} // namespace null_plugin +} // namespace proxy_wasm + +#endif \ No newline at end of file diff --git a/extensions/authn/sample/APToken/APToken-example1.jwt b/extensions/authn/sample/APToken/APToken-example1.jwt new file mode 100644 index 00000000000..82f1e6ab448 --- /dev/null +++ b/extensions/authn/sample/APToken/APToken-example1.jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0UnpIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJleGFtcGxlLWF1ZGllbmNlIiwiZW1haWwiOiJmb29AZ29vZ2xlLmNvbSIsImV4cCI6NDY5ODM2MTUwOCwiaWF0IjoxNTQ0NzYxNTA4LCJpc3MiOiJodHRwczovL2V4YW1wbGUudG9rZW5fc2VydmljZS5jb20iLCJpc3Rpb19hdHRyaWJ1dGVzIjpbeyJzb3VyY2UuaXAiOiIxMjcuMC4wLjEifV0sImtleTEiOlsidmFsMiIsInZhbDMiXSwib3JpZ2luYWxfY2xhaW1zIjp7ImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlzcyI6Imh0dHBzOi8vYWNjb3VudHMuZXhhbXBsZS5jb20iLCJzdWIiOiJleGFtcGxlLXN1YmplY3QifSwic3ViIjoiaHR0cHM6Ly9hY2NvdW50cy5leGFtcGxlLmNvbS8xMjM0NTU2Nzg5MCJ9.mLm9Gmcd748anwybiPxGPEuYgJBChqoHkVOvRhQN-H9jMqVKyF-7ynud1CJp5n72VeMB1FzvKAV0ErzSyWQc0iofQywG6whYXP6zL-Oc0igUrLDvzb6PuBDkbWOcZrvHkHM4tIYAkF4j880GqMWEP3gGrykziIEY9g4povquCFSdkLjjyol2-Ge_6MFdayYoeWLLOaMP7tHiPTm_ajioQ4jcz5whBWu3DZWx4IuU5UIBYlHG_miJZv5zmwwQ60T1_p_sW7zkABJgDhCvu6cHh6g-hZdQvZbATFwMfN8VDzttTjRG8wuLlkQ1TTOCx5PDv-_gHfQfRWt8Z94HrIJPuQ \ No newline at end of file diff --git a/extensions/authn/sample/APToken/aptoken-envoy.conf b/extensions/authn/sample/APToken/aptoken-envoy.conf new file mode 100644 index 00000000000..a5905812f7c --- /dev/null +++ b/extensions/authn/sample/APToken/aptoken-envoy.conf @@ -0,0 +1,118 @@ +{ + "admin": { + "access_log_path": "/dev/stdout", + "address": { + "socket_address": { + "address": "0.0.0.0", + "port_value": 9001 + } + } + }, + "static_resources": { + "clusters": [ + { + "name": "service1", + "connect_timeout": "5s", + "type": "STATIC", + "hosts": [ + { + "socket_address": { + "address": "0.0.0.0", + "port_value": 8080 + } + } + ] + } + ], + "listeners": [ + { + "name": "server", + "address": { + "socket_address": { + "address": "0.0.0.0", + "port_value": 9090 + } + }, + "filter_chains": [ + { + "filters": [ + { + "name": "envoy.http_connection_manager", + "config": { + "codec_type": "AUTO", + "stat_prefix": "inbound_http", + "access_log": [ + { + "name": "envoy.file_access_log", + "config": { + "path": "/tmp/envoy-access.log" + } + } + ], + "http_filters": [ + { + "name": "jwt-auth", + "config": { + "rules": [ + { + "issuer": "https://example.token_service.com", + "local_jwks": { + "inline_string": "{ \"keys\":[ {\"e\":\"AQAB\",\"kid\":\"DHFbpoIUqrY8t2zpA2qXfCmr5VO5ZEr4RzHU_-envvQ\",\"kty\":\"RSA\",\"n\":\"xAE7eB6qugXyCAG3yhh7pkDkT65pHymX-P7KfIupjf59vsdo91bSP9C8H07pSAGQO1MV_xFj9VswgsCg4R6otmg5PV2He95lZdHtOcU5DXIg_pbhLdKXbi66GlVeK6ABZOUW3WYtnNHD-91gVuoeJT_DwtGGcp4ignkgXfkiEm4sw-4sfb4qdt5oLbyVpmW6x9cfa7vs2WTfURiCrBoUqgBo_-4WTiULmmHSGZHOjzwa8WtrtOQGsAFjIbno85jp6MnGGGZPYZbDAa_b3y5u-YpW7ypZrvD8BgtKVjgtQgZhLAGezMt0ua3DRrWnKqTZ0BJ_EyxOGuHJrLsn00fnMQ\"}]}", + }, + "from_headers": [{"name": "ingress-authorization"}], + "forward_payload_header": "test-jwt-payload-output" + } + ] + } + }, + { + "name":"istio_authn", + "config":{ + "policy":{ + "origins":[ + { + "jwt":{ + "issuer":"https://example.token_service.com", + "jwt_headers":["ingress-authorization"] + } + } + ], + "principal_binding":1 + } + } + }, + { + "name": "envoy.router" + } + ], + "route_config": { + "name": "backend", + "virtual_hosts": [ + { + "name": "backend", + "domains": [ + "*" + ], + "routes": [ + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "service1", + "timeout": "0s" + } + } + ] + } + ] + } + } + } + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/extensions/authn/sample/APToken/guide.txt b/extensions/authn/sample/APToken/guide.txt new file mode 100644 index 00000000000..7fdf21c9d6f --- /dev/null +++ b/extensions/authn/sample/APToken/guide.txt @@ -0,0 +1,18 @@ +This is a guide of sending an example exchanged token to +the jwt-authn filter and the Istio authn filter, and observing +that the example backend echoes back the request when +the authentication succeeds. + +1. Open a terminal, go to the root directory of the istio-proxy repository. +Start the example backend: + go run test/backend/echo/echo.go + +2. Build the Istio proxy and run the proxy with the config for authenticating +an example exchanged token. + bazel build //src/envoy:envoy + bazel-bin/src/envoy/envoy -l debug -c src/envoy/http/jwt_auth/sample/APToken/aptoken-envoy.conf + +3. Open a terminal, go to the root directory of the istio-proxy repository. +Send a request with the example exchanged token. + export token=$(cat src/envoy/http/jwt_auth/sample/APToken/APToken-example1.jwt) + curl --header "ingress-authorization:$token" http://localhost:9090/echo -d "hello world" diff --git a/extensions/authn/test_utils.h b/extensions/authn/test_utils.h new file mode 100644 index 00000000000..c749fb89ffd --- /dev/null +++ b/extensions/authn/test_utils.h @@ -0,0 +1,52 @@ +/* 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 proxy_wasm { +namespace null_plugin { +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 null_plugin +} // namespace proxy_wasm diff --git a/extensions/common/BUILD b/extensions/common/BUILD index 8c6cd10fe88..d5fcdd9b36d 100644 --- a/extensions/common/BUILD +++ b/extensions/common/BUILD @@ -183,3 +183,24 @@ cc_library( "//external:abseil_strings", ], ) + +envoy_cc_library( + name = "base64", + hdrs = [ + "base64.h", + ], + repository = "@envoy", + visibility = ["//visibility:public"], +) + +# TODO(shikugawa): These redundant build strategy is caused by the build problem of abseil. +# This problem is resolved on https://github.com/abseil/abseil-cpp/pull/721 +# so we can destroy after that merged into upstream +cc_library( + name = "base64_wasm", + hdrs = [ + "base64.h", + ], + copts = ["-UNULL_PLUGIN"], + visibility = ["//visibility:public"], +) diff --git a/extensions/metadata_exchange/base64.h b/extensions/common/base64.h similarity index 94% rename from extensions/metadata_exchange/base64.h rename to extensions/common/base64.h index 329b7036c1e..f3c3ca7749d 100644 --- a/extensions/metadata_exchange/base64.h +++ b/extensions/common/base64.h @@ -20,6 +20,8 @@ #include +#include "absl/strings/string_view.h" + class Base64 { public: static std::string encode(const char* input, uint64_t length, @@ -27,14 +29,14 @@ class Base64 { static std::string encode(const char* input, uint64_t length) { return encode(input, length, true); } - static std::string decodeWithoutPadding(std::string_view input); + static std::string decodeWithoutPadding(absl::string_view input); }; // clang-format off -inline constexpr char CHAR_TABLE[] = +constexpr char CHAR_TABLE[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -inline constexpr unsigned char REVERSE_LOOKUP_TABLE[256] = { +constexpr unsigned char REVERSE_LOOKUP_TABLE[256] = { 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 62, 64, 64, 64, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 64, 64, 64, 64, 64, 64, 64, 0, 1, 2, 3, 4, 5, 6, @@ -157,9 +159,9 @@ inline std::string Base64::encode(const char* input, uint64_t length, return ret; } -inline std::string Base64::decodeWithoutPadding(StringView input) { +inline std::string Base64::decodeWithoutPadding(absl::string_view input) { if (input.empty()) { - return EMPTY_STRING; + return ""; } // At most last two chars can be '='. @@ -185,14 +187,14 @@ inline std::string Base64::decodeWithoutPadding(StringView input) { ret.reserve(max_length); for (uint64_t i = 0; i < last; ++i) { if (!decodeBase(input[i], i, ret, REVERSE_LOOKUP_TABLE)) { - return EMPTY_STRING; + return ""; } } if (!decodeLast(input[last], last, ret, REVERSE_LOOKUP_TABLE)) { - return EMPTY_STRING; + return ""; } - ASSERT(ret.size() == max_length); + assert(ret.size() == max_length); return ret; } diff --git a/extensions/metadata_exchange/BUILD b/extensions/metadata_exchange/BUILD index 1da81d7f815..e74f2e29db1 100644 --- a/extensions/metadata_exchange/BUILD +++ b/extensions/metadata_exchange/BUILD @@ -50,5 +50,3 @@ cc_proto_library( name = "declare_property_proto_cc", deps = [":declare_property_proto"], ) - -exports_files(["base64.h"]) diff --git a/src/envoy/utils/BUILD b/src/envoy/utils/BUILD index 9cb973e0432..a2751876e6a 100644 --- a/src/envoy/utils/BUILD +++ b/src/envoy/utils/BUILD @@ -67,7 +67,29 @@ envoy_cc_library( "//external:mixer_client_config_cc_proto", "//src/istio/control/http:control_lib", "//src/istio/mixerclient:mixerclient_lib", + "//include/istio/mixerclient:headers_lib", + "//include/istio/utils:headers_lib", "@envoy//source/exe:envoy_common_lib", + "@envoy//include/envoy/http:header_map_interface", + "@envoy//include/envoy/network:connection_interface", + "@envoy//include/envoy/event:dispatcher_interface", + ], +) + +cc_library( + name = "utils_lib_wasm", + srcs = [ + "utils.cc", + ], + hdrs = [ + "utils.h", + ], + visibility = ["//visibility:public"], + deps = [ + "//include/istio/mixerclient:headers_lib", + "//include/istio/utils:headers_lib", + "@envoy//include/envoy/http:header_map_interface", + "@envoy//include/envoy/network:connection_interface", ], ) diff --git a/src/istio/authn/BUILD b/src/istio/authn/BUILD index 79f3c18011c..049bed9a56e 100644 --- a/src/istio/authn/BUILD +++ b/src/istio/authn/BUILD @@ -27,3 +27,10 @@ envoy_proto_library( srcs = ["context.proto"], external_deps = ["well_known_protos"], ) + +cc_proto_library( + name = "context_proto_cc_wasm", + deps = [ + ":context_proto", + ], +) diff --git a/src/istio/authn/context.proto b/src/istio/authn/context.proto index 69cb3b009cb..3379520b39a 100644 --- a/src/istio/authn/context.proto +++ b/src/istio/authn/context.proto @@ -83,4 +83,4 @@ message Result { // Origin authentication supports only JWT at the moment, so we can use // JwtPayload for origin authenticated attributes. JwtPayload origin = 3; -} +} \ No newline at end of file