diff --git a/src/envoy/http/authn/authenticator_base.cc b/src/envoy/http/authn/authenticator_base.cc index 9262d249e25..53a877d17cd 100644 --- a/src/envoy/http/authn/authenticator_base.cc +++ b/src/envoy/http/authn/authenticator_base.cc @@ -29,6 +29,19 @@ namespace Http { namespace Istio { namespace AuthN { +namespace { +// The default header name for an exchanged token +static const std::string kExchangedTokenHeaderName = "ingress-authorization"; + +// Returns whether the header for an exchanged token is found +bool FindHeaderOfExchangedToken(const 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) {} @@ -68,7 +81,27 @@ bool AuthenticatorBase::validateX509(const iaapi::MutualTls& mtls, bool AuthenticatorBase::validateJwt(const iaapi::Jwt& jwt, Payload* payload) { std::string jwt_payload; if (filter_context()->getJwtPayload(jwt.issuer(), &jwt_payload)) { - return AuthnUtils::ProcessJwtPayload(jwt_payload, payload->mutable_jwt()); + 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. + ENVOY_LOG( + error, + "Expect exchanged-token with original payload claim. Received: {}", + jwt_payload); + return false; + } + } + return AuthnUtils::ProcessJwtPayload(payload_to_process, + payload->mutable_jwt()); } return false; } diff --git a/src/envoy/http/authn/authenticator_base_test.cc b/src/envoy/http/authn/authenticator_base_test.cc index 793fac11720..1a476f065e7 100644 --- a/src/envoy/http/authn/authenticator_base_test.cc +++ b/src/envoy/http/authn/authenticator_base_test.cc @@ -50,6 +50,31 @@ const std::string kSecIstioAuthUserinfoHeaderValue = } )"; +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) @@ -293,6 +318,86 @@ TEST_F(ValidateJwtTest, JwtPayloadAvailable) { 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())[Utils::IstioFilterName::kJwt] + .MergeFrom( + 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())[Utils::IstioFilterName::kJwt] + .MergeFrom(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())[Utils::IstioFilterName::kJwt] + .MergeFrom( + 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 Istio diff --git a/src/envoy/http/authn/authn_utils.cc b/src/envoy/http/authn/authn_utils.cc index 4a022da8c1e..df81d43f30d 100644 --- a/src/envoy/http/authn/authn_utils.cc +++ b/src/envoy/http/authn/authn_utils.cc @@ -27,6 +27,10 @@ namespace AuthN { namespace { // The JWT audience key name static const std::string kJwtAudienceKey = "aud"; +// The JWT issuer key name +static const std::string kJwtIssuerKey = "iss"; +// The key name for the original claims in an exchanged token +static const std::string kExchangedTokenOriginalPayload = "original_claims"; // Extract JWT claim as a string list. // This function only extracts string and string list claims. @@ -100,6 +104,36 @@ bool AuthnUtils::ProcessJwtPayload(const std::string& payload_str, return true; } +bool AuthnUtils::ExtractOriginalPayload(const std::string& token, + std::string* original_payload) { + Envoy::Json::ObjectSharedPtr json_obj; + try { + json_obj = Json::Factory::loadFromString(token); + } catch (...) { + return false; + } + + if (json_obj->hasObject(kExchangedTokenOriginalPayload) == false) { + return false; + } + + Envoy::Json::ObjectSharedPtr original_payload_obj; + try { + auto original_payload_obj = + json_obj->getObject(kExchangedTokenOriginalPayload); + *original_payload = original_payload_obj->asJsonString(); + ENVOY_LOG(debug, "{}: the original payload in exchanged token is {}", + __FUNCTION__, *original_payload); + } catch (...) { + ENVOY_LOG(debug, + "{}: original_payload in exchanged token is of invalid format.", + __FUNCTION__); + return false; + } + + return true; +} + bool AuthnUtils::MatchString(const char* const str, const iaapi::StringMatch& match) { if (str == nullptr) { diff --git a/src/envoy/http/authn/authn_utils.h b/src/envoy/http/authn/authn_utils.h index 5a0f3fc4d45..cf2a0e69d5b 100644 --- a/src/envoy/http/authn/authn_utils.h +++ b/src/envoy/http/authn/authn_utils.h @@ -38,6 +38,12 @@ class AuthnUtils : public Logger::Loggable { 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(const char* const str, const iaapi::StringMatch& match); diff --git a/src/envoy/http/authn/sample/APToken/APToken-example1.jwt b/src/envoy/http/authn/sample/APToken/APToken-example1.jwt new file mode 100644 index 00000000000..82f1e6ab448 --- /dev/null +++ b/src/envoy/http/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/src/envoy/http/authn/sample/APToken/aptoken-envoy.conf b/src/envoy/http/authn/sample/APToken/aptoken-envoy.conf new file mode 100644 index 00000000000..a5905812f7c --- /dev/null +++ b/src/envoy/http/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/src/envoy/http/authn/sample/APToken/guide.txt b/src/envoy/http/authn/sample/APToken/guide.txt new file mode 100644 index 00000000000..7fdf21c9d6f --- /dev/null +++ b/src/envoy/http/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/test/integration/BUILD b/test/integration/BUILD index 6d4d98562b2..5ea6b708487 100644 --- a/test/integration/BUILD +++ b/test/integration/BUILD @@ -35,4 +35,20 @@ envoy_cc_test( "//src/envoy/utils:filter_names_lib", "//src/envoy/http/mixer:filter_lib", ], -) \ No newline at end of file +) + +envoy_cc_test( + name = "exchanged_token_integration_test", + srcs = ["exchanged_token_integration_test.cc"], + repository = "@envoy", + deps = [ + "@envoy//source/common/common:utility_lib", + "@envoy//test/integration:http_protocol_integration_lib", + "//include/istio/utils:attribute_names_header", + "//src/envoy/http/authn:filter_lib", + "//src/envoy/http/jwt_auth:http_filter_factory", + "//src/envoy/http/jwt_auth:jwt_lib", + "//src/envoy/utils:filter_names_lib", + "//src/envoy/http/mixer:filter_lib", + ], +) diff --git a/test/integration/exchanged_token_integration_test.cc b/test/integration/exchanged_token_integration_test.cc new file mode 100644 index 00000000000..5827b072759 --- /dev/null +++ b/test/integration/exchanged_token_integration_test.cc @@ -0,0 +1,418 @@ +/* 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. + */ + +// The integration tests in this file test the end-to-end behaviour of +// an exchanged token when going through the HTTP filter chains +// (jwt-authn + istio-authn + istio-mixer). Filters pass on processing +// results next filters using the request info through dynamic metadata +// and the results generated by the filters can be observed at the mixer +// backend). + +#include "fmt/printf.h" +#include "gmock/gmock.h" +#include "include/istio/utils/attribute_names.h" +#include "mixer/v1/mixer.pb.h" +#include "src/envoy/utils/filter_names.h" +#include "test/integration/http_protocol_integration.h" + +using ::google::protobuf::util::error::Code; +using ::testing::Contains; +using ::testing::Not; + +namespace Envoy { +namespace { + +// An example exchanged token +constexpr char kExchangedToken[] = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0Un" + "pIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJleGFtcGxlLWF1ZGllbmNlIiwiZW1ha" + "WwiOiJmb29AZ29vZ2xlLmNvbSIsImV4cCI6NDY5ODM2MTUwOCwiaWF0IjoxNTQ0NzYxNTA4LCJ" + "pc3MiOiJodHRwczovL2V4YW1wbGUudG9rZW5fc2VydmljZS5jb20iLCJpc3Rpb19hdHRyaWJ1d" + "GVzIjpbeyJzb3VyY2UuaXAiOiIxMjcuMC4wLjEifV0sImtleTEiOlsidmFsMiIsInZhbDMiXSw" + "ib3JpZ2luYWxfY2xhaW1zIjp7ImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlzcyI6Imh0d" + "HBzOi8vYWNjb3VudHMuZXhhbXBsZS5jb20iLCJzdWIiOiJleGFtcGxlLXN1YmplY3QifSwic3V" + "iIjoiaHR0cHM6Ly9hY2NvdW50cy5leGFtcGxlLmNvbS8xMjM0NTU2Nzg5MCJ9.mLm9Gmcd748a" + "nwybiPxGPEuYgJBChqoHkVOvRhQN-H9jMqVKyF-7ynud1CJp5n72VeMB1FzvKAV0ErzSyWQc0i" + "ofQywG6whYXP6zL-Oc0igUrLDvzb6PuBDkbWOcZrvHkHM4tIYAkF4j880GqMWEP3gGrykziIEY" + "9g4povquCFSdkLjjyol2-Ge_6MFdayYoeWLLOaMP7tHiPTm_ajioQ4jcz5whBWu3DZWx4IuU5U" + "IBYlHG_miJZv5zmwwQ60T1_p_sW7zkABJgDhCvu6cHh6g-hZdQvZbATFwMfN8VDzttTjRG8wuL" + "lkQ1TTOCx5PDv-_gHfQfRWt8Z94HrIJPuQ"; + +// An example token without original_claims +constexpr char kTokenWithoutOriginalClaims[] = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0Un" + "pIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJleGFtcGxlLWF1ZGllbmNlIiwiZW1ha" + "WwiOiJmb29AZ29vZ2xlLmNvbSIsImV4cCI6NDY5ODcyNzc2NiwiaWF0IjoxNTQ1MTI3NzY2LCJ" + "pc3MiOiJodHRwczovL2V4YW1wbGUudG9rZW5fc2VydmljZS5jb20iLCJpc3Rpb19hdHRyaWJ1d" + "GVzIjpbeyJzb3VyY2UuaXAiOiIxMjcuMC4wLjEifV0sImtleTEiOlsidmFsMiIsInZhbDMiXSw" + "ic3ViIjoiaHR0cHM6Ly9hY2NvdW50cy5leGFtcGxlLmNvbS8xMjM0NTU2Nzg5MCJ9.FVskjGxS" + "cTuNFtKGRnQvQgejgcdPbunCAbXlj_ZYMawrHIYnrMt_Ddw5nOojxQu2zfkwoB004196ozNjDR" + "ED4jpJA0T6HP7hyTHGbrp6h6Z4dQ_PcmAxdR2_g8GEo-bcJ-CcbATEyBtrDqLtFcgP-ev_ctAo" + "BQHGp7qMgdpkQIJ07BTT1n6mghPFFCnA__RYWjPUwMLGZs_bOtWxHYbd-bkDSwg4Kbtf5-9oPI" + "nwJc6oMGMVzdjmJYMadg5GEor5XhgYz3TThPzLlEsxa0loD9eJDBGgdwjA1cLuAGgM7_HgRfg7" + "8ameSmQgSCsNlFB4k3ODeC-YC62KYdZ5Jdrg2A"; + +constexpr char kExpectedPrincipal[] = + "https://accounts.example.com/example-subject"; +constexpr char kDestinationNamespace[] = "pod"; +constexpr char kDestinationUID[] = "kubernetes://dest.pod"; +constexpr char kSourceUID[] = "kubernetes://src.pod"; +constexpr char kTelemetryBackend[] = "telemetry-backend"; +constexpr char kPolicyBackend[] = "policy-backend"; +const std::string kHeaderForExchangedToken = "ingress-authorization"; + +// Generates basic test request header. +Http::TestHeaderMapImpl BaseRequestHeaders() { + return Http::TestHeaderMapImpl{{":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-forwarded-for", "10.0.0.1"}}; +} + +// Generates test request header with given token. +Http::TestHeaderMapImpl HeadersWithToken(const std::string& header, + const std::string& token) { + auto headers = BaseRequestHeaders(); + headers.addCopy(header, token); + return headers; +} + +std::string MakeJwtFilterConfig() { + constexpr char kJwtFilterTemplate[] = R"( + name: %s + config: + rules: + - issuer: "https://example.token_service.com" + from_headers: + - name: ingress-authorization + local_jwks: + inline_string: "%s" + - issuer: "testing-rbac@secure.istio.io" + local_jwks: + inline_string: "%s" + allow_missing_or_failed: true + )"; + // From + // https://github.com/istio/istio/blob/master/security/tools/jwt/samples/jwks.json + constexpr char kJwksInline[] = + "{ \"keys\":[ " + "{\"e\":\"AQAB\",\"kid\":\"DHFbpoIUqrY8t2zpA2qXfCmr5VO5ZEr4RzHU_-envvQ\"," + "\"kty\":\"RSA\",\"n\":\"xAE7eB6qugXyCAG3yhh7pkDkT65pHymX-" + "P7KfIupjf59vsdo91bSP9C8H07pSAGQO1MV" + "_xFj9VswgsCg4R6otmg5PV2He95lZdHtOcU5DXIg_" + "pbhLdKXbi66GlVeK6ABZOUW3WYtnNHD-91gVuoeJT_" + "DwtGGcp4ignkgXfkiEm4sw-4sfb4qdt5oLbyVpmW6x9cfa7vs2WTfURiCrBoUqgBo_-" + "4WTiULmmHSGZHOjzwa8WtrtOQGsAFjIbno85jp6MnGGGZPYZbDAa_b3y5u-" + "YpW7ypZrvD8BgtKVjgtQgZhLAGezMt0ua3DRrWnKqTZ0BJ_EyxOGuHJrLsn00fnMQ\"}]}"; + + return fmt::sprintf(kJwtFilterTemplate, Utils::IstioFilterName::kJwt, + StringUtil::escape(kJwksInline), + StringUtil::escape(kJwksInline)); +} + +std::string MakeAuthFilterConfig() { + constexpr char kAuthnFilterWithJwtTemplate[] = R"( + name: %s + config: + policy: + origins: + - jwt: + issuer: https://example.token_service.com + jwt_headers: + - ingress-authorization + principalBinding: USE_ORIGIN +)"; + return fmt::sprintf(kAuthnFilterWithJwtTemplate, + Utils::IstioFilterName::kAuthentication); +} + +std::string MakeRbacFilterConfig() { + constexpr char kRbacFilterTemplate[] = R"( + name: envoy.filters.http.rbac + config: + rules: + policies: + "foo": + permissions: + - any: true + principals: + - metadata: + filter: %s + path: + - key: %s + value: + string_match: + exact: %s +)"; + return fmt::sprintf( + kRbacFilterTemplate, Utils::IstioFilterName::kAuthentication, + istio::utils::AttributeName::kRequestAuthPrincipal, kExpectedPrincipal); +} + +std::string MakeMixerFilterConfig() { + constexpr char kMixerFilterTemplate[] = R"( + name: mixer + config: + defaultDestinationService: "default" + mixerAttributes: + attributes: { + } + serviceConfigs: { + "default": {} + } + transport: + attributes_for_mixer_proxy: + attributes: { + "source.uid": { + string_value: %s + } + } + report_cluster: %s + check_cluster: %s + )"; + return fmt::sprintf(kMixerFilterTemplate, kSourceUID, kTelemetryBackend, + kPolicyBackend); +} + +class ExchangedTokenIntegrationTest : public HttpProtocolIntegrationTest { + public: + void createUpstreams() override { + HttpProtocolIntegrationTest::createUpstreams(); + fake_upstreams_.emplace_back(new FakeUpstream( + 0, FakeHttpConnection::Type::HTTP2, version_, timeSystem())); + telemetry_upstream_ = fake_upstreams_.back().get(); + + fake_upstreams_.emplace_back(new FakeUpstream( + 0, FakeHttpConnection::Type::HTTP2, version_, timeSystem())); + policy_upstream_ = fake_upstreams_.back().get(); + } + + void SetUp() override { + config_helper_.addConfigModifier(addNodeMetadata()); + + config_helper_.addFilter(MakeMixerFilterConfig()); + config_helper_.addFilter(MakeRbacFilterConfig()); + config_helper_.addFilter(MakeAuthFilterConfig()); + config_helper_.addFilter(MakeJwtFilterConfig()); + + config_helper_.addConfigModifier(addCluster(kTelemetryBackend)); + config_helper_.addConfigModifier(addCluster(kPolicyBackend)); + + HttpProtocolIntegrationTest::initialize(); + } + + void TearDown() override { + cleanupConnection(fake_upstream_connection_); + cleanupConnection(telemetry_connection_); + cleanupConnection(policy_connection_); + } + + ConfigHelper::ConfigModifierFunction addNodeMetadata() { + return [](envoy::config::bootstrap::v2::Bootstrap& bootstrap) { + ::google::protobuf::Struct meta; + MessageUtil::loadFromJson( + fmt::sprintf(R"({ + "ISTIO_VERSION": "1.0.1", + "NODE_UID": "%s", + "NODE_NAMESPACE": "%s" + })", + kDestinationUID, kDestinationNamespace), + meta); + bootstrap.mutable_node()->mutable_metadata()->MergeFrom(meta); + }; + } + + ConfigHelper::ConfigModifierFunction addCluster(const std::string& name) { + return [name](envoy::config::bootstrap::v2::Bootstrap& bootstrap) { + auto* cluster = bootstrap.mutable_static_resources()->add_clusters(); + cluster->MergeFrom(bootstrap.static_resources().clusters()[0]); + cluster->mutable_http2_protocol_options(); + cluster->set_name(name); + }; + } + + void waitForTelemetryRequest(::istio::mixer::v1::ReportRequest* request) { + AssertionResult result = telemetry_upstream_->waitForHttpConnection( + *dispatcher_, telemetry_connection_); + RELEASE_ASSERT(result, result.message()); + result = telemetry_connection_->waitForNewStream(*dispatcher_, + telemetry_request_); + RELEASE_ASSERT(result, result.message()); + + result = telemetry_request_->waitForGrpcMessage(*dispatcher_, *request); + RELEASE_ASSERT(result, result.message()); + } + + // Must be called after waitForTelemetryRequest + void sendTelemetryResponse() { + telemetry_request_->startGrpcStream(); + telemetry_request_->sendGrpcMessage(::istio::mixer::v1::ReportResponse{}); + telemetry_request_->finishGrpcStream(Grpc::Status::Ok); + } + + void waitForPolicyRequest(::istio::mixer::v1::CheckRequest* request) { + AssertionResult result = policy_upstream_->waitForHttpConnection( + *dispatcher_, policy_connection_); + RELEASE_ASSERT(result, result.message()); + result = + policy_connection_->waitForNewStream(*dispatcher_, policy_request_); + RELEASE_ASSERT(result, result.message()); + + result = policy_request_->waitForGrpcMessage(*dispatcher_, *request); + RELEASE_ASSERT(result, result.message()); + } + + // Must be called after waitForPolicyRequest + void sendPolicyResponse() { + policy_request_->startGrpcStream(); + ::istio::mixer::v1::CheckResponse response; + response.mutable_precondition()->mutable_status()->set_code(Code::OK); + policy_request_->sendGrpcMessage(response); + policy_request_->finishGrpcStream(Grpc::Status::Ok); + } + + void cleanupConnection(FakeHttpConnectionPtr& connection) { + if (connection != nullptr) { + AssertionResult result = connection->close(); + RELEASE_ASSERT(result, result.message()); + result = connection->waitForDisconnect(); + RELEASE_ASSERT(result, result.message()); + } + } + + FakeUpstream* telemetry_upstream_{}; + FakeHttpConnectionPtr telemetry_connection_{}; + FakeStreamPtr telemetry_request_{}; + + FakeUpstream* policy_upstream_{}; + FakeHttpConnectionPtr policy_connection_{}; + FakeStreamPtr policy_request_{}; +}; + +INSTANTIATE_TEST_CASE_P( + Protocols, ExchangedTokenIntegrationTest, + testing::ValuesIn(HttpProtocolIntegrationTest::getProtocolTestParams()), + HttpProtocolIntegrationTest::protocolTestParamsToString); + +TEST_P(ExchangedTokenIntegrationTest, ValidExchangeToken) { + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + + // A valid exchanged token in the header for an exchanged token + auto response = codec_client_->makeHeaderOnlyRequest( + HeadersWithToken(kHeaderForExchangedToken, kExchangedToken)); + + ::istio::mixer::v1::CheckRequest check_request; + waitForPolicyRequest(&check_request); + // Check request should see the authn attributes in the original payload. + EXPECT_THAT( + check_request.attributes().words(), + ::testing::AllOf(Contains(kDestinationUID), Contains("10.0.0.1"), + Contains(kExpectedPrincipal), Contains("sub"), + Contains("example-subject"), Contains("iss"), + Contains("https://accounts.example.com"), + Contains("email"), Contains("user@example.com"))); + sendPolicyResponse(); + + waitForNextUpstreamRequest(0); + // Send backend response. + upstream_request_->encodeHeaders(Http::TestHeaderMapImpl{{":status", "200"}}, + true); + response->waitForEndStream(); + + // Report is sent after the backend responds. + ::istio::mixer::v1::ReportRequest report_request; + waitForTelemetryRequest(&report_request); + // Report request should also see the same authn attributes. + EXPECT_THAT( + report_request.default_words(), + ::testing::AllOf(Contains(kDestinationUID), Contains("10.0.0.1"), + Contains(kExpectedPrincipal), Contains("sub"), + Contains("example-subject"), Contains("iss"), + Contains("https://accounts.example.com"), + Contains("email"), Contains("user@example.com"))); + + sendTelemetryResponse(); + + EXPECT_TRUE(response->complete()); + EXPECT_STREQ("200", response->headers().Status()->value().c_str()); +} + +TEST_P(ExchangedTokenIntegrationTest, ValidExchangeTokenAtWrongHeader) { + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + + // When a token is not in the header for an exchanged token, + // it will not be regarded as an exchanged token. + auto response = codec_client_->makeHeaderOnlyRequest( + HeadersWithToken("wrong-header", kExchangedToken)); + + ::istio::mixer::v1::ReportRequest report_request; + waitForTelemetryRequest(&report_request); + EXPECT_THAT(report_request.default_words(), + ::testing::AllOf(Contains(kDestinationUID), Contains("10.0.0.1"), + Not(Contains(kExpectedPrincipal)))); + sendTelemetryResponse(); + + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_STREQ("401", response->headers().Status()->value().c_str()); +} + +TEST_P(ExchangedTokenIntegrationTest, TokenWithoutOriginalClaims) { + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + + // When a token does not contain original_claims, + // it will be regarded as an invalid exchanged token. + auto response = codec_client_->makeHeaderOnlyRequest( + HeadersWithToken(kHeaderForExchangedToken, kTokenWithoutOriginalClaims)); + + ::istio::mixer::v1::ReportRequest report_request; + waitForTelemetryRequest(&report_request); + EXPECT_THAT(report_request.default_words(), + ::testing::AllOf(Contains(kDestinationUID), Contains("10.0.0.1"), + Not(Contains(kExpectedPrincipal)))); + sendTelemetryResponse(); + + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_STREQ("401", response->headers().Status()->value().c_str()); +} + +TEST_P(ExchangedTokenIntegrationTest, InvalidExchangeToken) { + codec_client_ = + makeHttpConnection(makeClientConnection((lookupPort("http")))); + + // When an invalid exchanged token is in the header for an exchanged token, + // the request will be rejected. + auto response = codec_client_->makeHeaderOnlyRequest( + HeadersWithToken(kHeaderForExchangedToken, "invalid-token")); + + ::istio::mixer::v1::ReportRequest report_request; + waitForTelemetryRequest(&report_request); + EXPECT_THAT(report_request.default_words(), + ::testing::AllOf(Contains(kDestinationUID), Contains("10.0.0.1"), + Not(Contains(kExpectedPrincipal)))); + sendTelemetryResponse(); + + response->waitForEndStream(); + EXPECT_TRUE(response->complete()); + EXPECT_STREQ("401", response->headers().Status()->value().c_str()); +} + +} // namespace +} // namespace Envoy