From ada3938115ac74afe1370287506a87ce2c00bc58 Mon Sep 17 00:00:00 2001 From: Ronald Date: Thu, 6 Jul 2023 07:27:30 -0400 Subject: [PATCH] Add first integration test for jwt auth with intention (#18005) --- agent/xds/listeners.go | 5 +- test/integration/consul-container/go.mod | 2 + test/integration/consul-container/go.sum | 6 + .../consul-container/libs/utils/helpers.go | 104 +++++++++ .../test/jwtauth/jwt_auth_test.go | 215 ++++++++++++++++++ 5 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 test/integration/consul-container/test/jwtauth/jwt_auth_test.go diff --git a/agent/xds/listeners.go b/agent/xds/listeners.go index 4278c3a8b6..6e67cd1c56 100644 --- a/agent/xds/listeners.go +++ b/agent/xds/listeners.go @@ -1381,12 +1381,11 @@ func (s *ResourceGenerator) makeInboundListener(cfgSnap *proxycfg.ConfigSnapshot if err != nil { return nil, err } - - filterOpts.httpAuthzFilters = []*envoy_http_v3.HttpFilter{rbacFilter} - + filterOpts.httpAuthzFilters = []*envoy_http_v3.HttpFilter{} if jwtFilter != nil { filterOpts.httpAuthzFilters = append(filterOpts.httpAuthzFilters, jwtFilter) } + filterOpts.httpAuthzFilters = append(filterOpts.httpAuthzFilters, rbacFilter) meshConfig := cfgSnap.MeshConfig() includeXFCC := meshConfig == nil || meshConfig.HTTP == nil || !meshConfig.HTTP.SanitizeXForwardedClientCert diff --git a/test/integration/consul-container/go.mod b/test/integration/consul-container/go.mod index 46a4d21c9b..9eba92ca55 100644 --- a/test/integration/consul-container/go.mod +++ b/test/integration/consul-container/go.mod @@ -7,6 +7,7 @@ require ( github.com/avast/retry-go v3.0.0+incompatible github.com/docker/docker v23.0.6+incompatible github.com/docker/go-connections v0.4.0 + github.com/go-jose/go-jose/v3 v3.0.0 github.com/hashicorp/consul v0.0.0-00010101000000-000000000000 github.com/hashicorp/consul/api v1.22.0-rc1 github.com/hashicorp/consul/envoyextensions v0.3.0-rc1 @@ -83,6 +84,7 @@ require ( github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/stretchr/objx v0.5.0 // indirect + golang.org/x/crypto v0.1.0 // indirect golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/sync v0.2.0 // indirect diff --git a/test/integration/consul-container/go.sum b/test/integration/consul-container/go.sum index b7ae09743d..02a74ddbe6 100644 --- a/test/integration/consul-container/go.sum +++ b/test/integration/consul-container/go.sum @@ -79,6 +79,8 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= +github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -101,6 +103,7 @@ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -286,6 +289,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= @@ -303,10 +307,12 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= diff --git a/test/integration/consul-container/libs/utils/helpers.go b/test/integration/consul-container/libs/utils/helpers.go index e18293a96a..5f75e3e4b3 100644 --- a/test/integration/consul-container/libs/utils/helpers.go +++ b/test/integration/consul-container/libs/utils/helpers.go @@ -4,6 +4,15 @@ package utils import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + + "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v3/jwt" "github.com/hashicorp/consul/api" ) @@ -18,3 +27,98 @@ func ApplyDefaultProxySettings(c *api.Client) (bool, error) { ok, _, err := c.ConfigEntries().Set(req, &api.WriteOptions{}) return ok, err } + +// Generates a private and public key pair that is for signing +// JWT. +func GenerateKey() (pub, priv string, err error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + if err != nil { + return "", "", fmt.Errorf("error generating private key: %w", err) + } + + { + derBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return "", "", fmt.Errorf("error marshaling private key: %w", err) + } + priv = string(pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: derBytes, + })) + } + { + derBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + return "", "", fmt.Errorf("error marshaling public key: %w", err) + } + pub = string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: derBytes, + })) + } + + return pub, priv, nil +} + +// SignJWT will bundle the provided claims into a signed JWT. The provided key +// is assumed to be ECDSA. +// +// If no private key is provided, it will generate a private key. These can +// be retrieved via the SigningKeys() method. +func SignJWT(privKey string, claims jwt.Claims, privateClaims interface{}) (string, error) { + var err error + if privKey == "" { + _, privKey, err = GenerateKey() + if err != nil { + return "", err + } + } + var key *ecdsa.PrivateKey + block, _ := pem.Decode([]byte(privKey)) + if block != nil { + key, err = x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return "", err + } + } + + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: key}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return "", err + } + + raw, err := jwt.Signed(sig). + Claims(claims). + Claims(privateClaims). + CompactSerialize() + if err != nil { + return "", err + } + + return raw, nil +} + +// newJWKS converts a pem-encoded public key into JWKS data suitable for a +// verification endpoint response +func NewJWKS(pubKey string) (*jose.JSONWebKeySet, error) { + block, _ := pem.Decode([]byte(pubKey)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("unable to decode public key") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + return &jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: pub, + }, + }, + }, nil +} diff --git a/test/integration/consul-container/test/jwtauth/jwt_auth_test.go b/test/integration/consul-container/test/jwtauth/jwt_auth_test.go new file mode 100644 index 0000000000..37c846d0a6 --- /dev/null +++ b/test/integration/consul-container/test/jwtauth/jwt_auth_test.go @@ -0,0 +1,215 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jwtauth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/stretchr/testify/require" + + "github.com/go-jose/go-jose/v3/jwt" + libassert "github.com/hashicorp/consul/test/integration/consul-container/libs/assert" + libcluster "github.com/hashicorp/consul/test/integration/consul-container/libs/cluster" + libservice "github.com/hashicorp/consul/test/integration/consul-container/libs/service" + libtopology "github.com/hashicorp/consul/test/integration/consul-container/libs/topology" + libutils "github.com/hashicorp/consul/test/integration/consul-container/libs/utils" + "github.com/hashicorp/go-cleanhttp" + "testing" + "time" +) + +// TestJWTAuthConnectService summary +// This test ensures that when we have an intention referencing a JWT, requests +// without JWT authorization headers are denied. And requests with the correct JWT +// Authorization header are successful +// +// Steps: +// - Creates a single agent cluster +// - Creates a static-server and sidecar containers +// - Registers the created static-server and sidecar with consul +// - Create a static-client and sidecar containers +// - Registers the static-client and sidecar with consul +// - Ensure client sidecar is running as expected +// - Make a request without the JWT Authorization header and expects 401 StatusUnauthorized +// - Make a request with the JWT Authorization header and expects a 200 +func TestJWTAuthConnectService(t *testing.T) { + t.Parallel() + + cluster, _, _ := libtopology.NewCluster(t, &libtopology.ClusterConfig{ + NumServers: 1, + NumClients: 1, + ApplyDefaultProxySettings: true, + BuildOpts: &libcluster.BuildOptions{ + Datacenter: "dc1", + InjectAutoEncryption: true, + InjectGossipEncryption: true, + }, + }) + + clientService := createServices(t, cluster) + _, clientPort := clientService.GetAddr() + _, clientAdminPort := clientService.GetAdminAddr() + + libassert.AssertUpstreamEndpointStatus(t, clientAdminPort, "static-server.default", "HEALTHY", 1) + libassert.AssertContainerState(t, clientService, "running") + libassert.AssertFortioName(t, fmt.Sprintf("http://localhost:%d", clientPort), "static-server", "") + + claims := jwt.Claims{ + Subject: "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients", + Audience: jwt.Audience{"https://consul.test"}, + Issuer: "https://legit.issuer.internal/", + NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)), + Expiry: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)), + } + + jwks, jwt := makeJWKSAndJWT(t, claims) + + // configure proxy-defaults, jwt-provider and intention + configureProxyDefaults(t, cluster) + configureJWTProvider(t, cluster, jwks, claims) + configureIntentions(t, cluster) + + baseURL := fmt.Sprintf("http://localhost:%d", clientPort) + // fails without jwt headers + doRequest(t, baseURL, http.StatusUnauthorized, "") + // succeeds with jwt + doRequest(t, baseURL, http.StatusOK, jwt) +} + +func createServices(t *testing.T, cluster *libcluster.Cluster) libservice.Service { + node := cluster.Agents[0] + client := node.GetClient() + // Create a service and proxy instance + serviceOpts := &libservice.ServiceOpts{ + Name: libservice.StaticServerServiceName, + ID: "static-server", + HTTPPort: 8080, + GRPCPort: 8079, + } + + // Create a service and proxy instance + _, _, err := libservice.CreateAndRegisterStaticServerAndSidecar(node, serviceOpts) + require.NoError(t, err) + + libassert.CatalogServiceExists(t, client, "static-server-sidecar-proxy", nil) + libassert.CatalogServiceExists(t, client, libservice.StaticServerServiceName, nil) + + // Create a client proxy instance with the server as an upstream + clientConnectProxy, err := libservice.CreateAndRegisterStaticClientSidecar(node, "", false, false) + require.NoError(t, err) + + libassert.CatalogServiceExists(t, client, "static-client-sidecar-proxy", nil) + + return clientConnectProxy +} + +// creates a JWKS and JWT that will be used for validation +func makeJWKSAndJWT(t *testing.T, claims jwt.Claims) (string, string) { + pub, priv, err := libutils.GenerateKey() + require.NoError(t, err) + + jwks, err := libutils.NewJWKS(pub) + require.NoError(t, err) + + jwksJson, err := json.Marshal(jwks) + require.NoError(t, err) + + type orgs struct { + Primary string `json:"primary"` + } + privateCl := struct { + FirstName string `json:"first_name"` + Org orgs `json:"org"` + Groups []string `json:"groups"` + }{ + FirstName: "jeff2", + Org: orgs{"engineering"}, + Groups: []string{"foo", "bar"}, + } + + jwt, err := libutils.SignJWT(priv, claims, privateCl) + require.NoError(t, err) + return string(jwksJson), jwt +} + +// configures the protocol to http as this is needed for jwt-auth +func configureProxyDefaults(t *testing.T, cluster *libcluster.Cluster) { + client := cluster.Agents[0].GetClient() + + ok, _, err := client.ConfigEntries().Set(&api.ProxyConfigEntry{ + Kind: api.ProxyDefaults, + Name: api.ProxyConfigGlobal, + Config: map[string]interface{}{ + "protocol": "http", + }, + }, nil) + require.NoError(t, err) + require.True(t, ok) +} + +// creates a JWT local provider +func configureJWTProvider(t *testing.T, cluster *libcluster.Cluster, jwks string, claims jwt.Claims) { + client := cluster.Agents[0].GetClient() + + ok, _, err := client.ConfigEntries().Set(&api.JWTProviderConfigEntry{ + Kind: api.JWTProvider, + Name: "test-jwt", + JSONWebKeySet: &api.JSONWebKeySet{ + Local: &api.LocalJWKS{ + JWKS: base64.StdEncoding.EncodeToString([]byte(jwks)), + }, + }, + Issuer: claims.Issuer, + Audiences: claims.Audience, + }, nil) + require.NoError(t, err) + require.True(t, ok) +} + +// creates an intention referencing the jwt provider +func configureIntentions(t *testing.T, cluster *libcluster.Cluster) { + client := cluster.Agents[0].GetClient() + + ok, _, err := client.ConfigEntries().Set(&api.ServiceIntentionsConfigEntry{ + Kind: "service-intentions", + Name: libservice.StaticServerServiceName, + Sources: []*api.SourceIntention{ + { + Name: libservice.StaticClientServiceName, + Action: api.IntentionActionAllow, + }, + }, + JWT: &api.IntentionJWTRequirement{ + Providers: []*api.IntentionJWTProvider{ + { + Name: "test-jwt", + VerifyClaims: []*api.IntentionJWTClaimVerification{}, + }, + }, + }, + }, nil) + require.NoError(t, err) + require.True(t, ok) +} + +func doRequest(t *testing.T, url string, expStatus int, jwt string) { + retry.RunWith(&retry.Timer{Timeout: 5 * time.Second, Wait: time.Second}, t, func(r *retry.R) { + + client := cleanhttp.DefaultClient() + + req, err := http.NewRequest("GET", url, nil) + require.NoError(r, err) + if jwt != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) + } + resp, err := client.Do(req) + require.NoError(r, err) + require.Equal(r, expStatus, resp.StatusCode) + }) +}